# -*- coding: utf-8 -*-
# Moovida - Home multimedia server
# Copyright (C) 2006-2009 Fluendo Embedded S.L. (www.fluendo.com).
# All rights reserved.
#
# This file is available under one of two license agreements.
#
# This file is licensed under the GPL version 3.
# See "LICENSE.GPL" in the root of this distribution including a special
# exception to use Moovida with Fluendo's plugins.
#
# The GPL part of Moovida is also available under a commercial licensing
# agreement from Fluendo.
# See "LICENSE.Moovida" in the root directory of this distribution package
# for details on that license.

from elisa.core import log
from elisa.core.input_event import EventValue

from elisa.plugins.pigment.graph.group import Group

from elisa.plugins.pigment.widgets.style import Style
from elisa.plugins.pigment.widgets.theme import Theme
from elisa.plugins.pigment.widgets.const import *

from elisa.plugins.pigment.animation.animation import Animation
from elisa.plugins.pigment.animation.ticker import Ticker

import pgm
import gobject

import copy
import logging


class Widget(Group):
    """
    Basic widget for the python Pigment scenegraph.

    It handles a simple focus system (at most one widget having the focus at
    one time) and a simple style system (with style and style-properties change
    notifications, also bound to state changes).

    Emit these signals:
      - focus
      - state-changed
      - style-set
      - key-press-event

    @ivar name: the name of the widget
    @type name: string
    @ivar style: the present style of the widget
    @type style: L{pgm.widget.Style}
    @ivar state: the present state of the widget
    @type state: enum(STATE_NORMAL, STATE_PRESSED, STATE_HOVERED,
                      STATE_SELECTED, STATE_DISABLED, STATE_CHECKED)
    """

    __gsignals__ = {'focus': (gobject.SIGNAL_RUN_LAST,
                              gobject.TYPE_BOOLEAN,
                              (gobject.TYPE_BOOLEAN,)),
                    'state-changed': (gobject.SIGNAL_RUN_LAST,
                                      gobject.TYPE_BOOLEAN,
                                      (gobject.TYPE_PYOBJECT,)),
                    'style-set': (gobject.SIGNAL_RUN_LAST,
                                  gobject.TYPE_BOOLEAN,
                                  (gobject.TYPE_PYOBJECT,)),
                    'key-press-event': (gobject.SIGNAL_RUN_LAST,
                                        gobject.TYPE_BOOLEAN,
                                        (gobject.TYPE_PYOBJECT,
                                         gobject.TYPE_PYOBJECT,
                                         gobject.TYPE_PYOBJECT))}

    # The widget with the focus in the direct acyclic graph
    _focused = None

    def __init__(self):
        """
        Set the default styles for the widget. Subclasses should override and
        call "self.update_style_properties(self.style.get_items())" after
        calling this base class constructor.
        """

        super(Widget, self).__init__()

        self.name = None

        self._initializing = True

        self._state = STATE_NORMAL
        self._style = None
        # The style properties for which the setattr() calls have been made
        self._committed_style = {}

        self._theme = None
        default_theme = Theme.get_default()
        self._init_styles(default_theme)

        self._initializing = False

        self._focus = False
        self._focus_proxy = None

        self._navigation_rules = {}

        self.connect("mapped", self._mapped)

    def _mapped(self, signal_sender):
        # The style property updates were not committed while the widget
        # wasn't visible, so we need to update now.
        self._commit_style_update(self.style)

    @classmethod
    def _init_class_styles(cls, theme):
        # build a list of all parent classes in reverse order of inheritance
        classes = ['%s.%s' % (base.__module__, base.__name__) \
                   for base in reversed(cls.__mro__) if issubclass(base, Widget)]

        def merge_styles_from_ancestor(style, state):
            for class_path in classes:
                class_style = theme.get_style_for_widget(class_path, state)
                if class_style != None:
                    style.update(class_style)

        # compute default style
        default_style = Style()
        merge_styles_from_ancestor(default_style, None)

        # compute styles for other states; they inherit from default style
        states = (STATE_NORMAL, \
                  STATE_PRESSED, \
                  STATE_HOVERED, \
                  STATE_SELECTED, \
                  STATE_DISABLED, \
                  STATE_LOADING)

        cls._styles = {}
        for state in states:
            style = Style()
            # inherit values from default style
            style.update(default_style)
            merge_styles_from_ancestor(style, state)
            cls._styles[state] = style

    def load_theme(self, theme):
        """
        DOCME
        """
        self._theme = theme
        self.__class__._init_class_styles(theme)
        style = self._get_style_for_state(self._state)
        self.style = style

    def _init_styles(self, theme):
        # If self is the first instance of its class initialize the styles for
        # the class; they will be stored in '_styles' class variable so that
        # the computation is done only once.
        cls = self.__class__
        if not cls.__dict__.has_key("_styles") or theme != cls._theme:
            # store a reference to the theme necessary to be able to reinitialise
            # the styles when it changes
            cls._theme = theme
            cls._init_class_styles(theme)

        self._theme = theme
        style = self._get_style_for_state(self._state)
        self.style = style

    def _merge_style_from_widget_name(self, style, state):
        """
        Merge styles specific to the instance's name if not None
        into L{style}.
        A specific instance can be targeted in a stylesheet using the
        following syntax:

          mymodule.WidgetClass#name {
            property: value;
          }

        Example:
          "pigment.widgets.list_vertical.ListVertical#myfirstlist" matches
          all instances of pigment.widgets.list_vertical.ListVertical
          whose widget name is 'myfirstlist'
        """
        def get_style_from_widget_name(name, state):
            widget_path = '%s.%s#%s' % (self.__class__.__module__, \
                                        self.__class__.__name__, name)
            return self._theme.get_style_for_widget(widget_path, state)

        if self.name != None:
            name_default_style = get_style_from_widget_name(self.name, None)
            if name_default_style != None:
                style.update(name_default_style)

            name_style = get_style_from_widget_name(self.name, state)
            if name_style != None:
                style.update(name_style)

    def _get_style_for_state(self, state):
        """
        Return a style corresponding to a given L{state}.
        A specific state can be targeted in a stylesheet using the
        following syntax:

         mymodule.WidgetClass:STATE_NORMAL {
           property: value;
         }
        """
        # style is intentionally a regular Python dictionary and not a Style
        # object. Both are guaranteed to have the same interface but writing
        # values to a Style instance costs more because of the notification
        # system.
        style = {}
        style.update(self._styles[state])
        self._merge_style_from_widget_name(style, state)
        style = Style(style)
        return style

    def set_name(self, name):
        """
        Set the name of the widget and potentially update its style if name
        specific rules were declared in the stylesheets.

        @param name: the new name
        @type name: string
        """
        self.name = name
        self._merge_style_from_widget_name(self.style, self._state)

    def state__get(self):
        """The present state of the widget"""
        return self._state

    def state__set(self, state):
        """
        Set the state of the widget and emit the "state-changed" signal if
        necessary.

        @param state: the new state
        @type state: enum(STATE_NORMAL, STATE_PRESSED, STATE_HOVERED,
                          STATE_SELECTED, STATE_DISABLED, STATE_CHECKED,
                          STATE_LOADING)
        """
        previous_state = self._state
        if state != previous_state:
            self._state = state
            self.emit("state-changed", previous_state)

    state = property(state__get, state__set)

    def do_state_changed(self, previous_state):
        """Default 'state-changed' signal handler"""
        style = self._get_style_for_state(self._state)
        self.style.update(style)

    def style__get(self):
        """The present style"""
        return self._style

    def style__set(self, style):
        """
        Set the present style, after binding it to the widget and subscribing
        for property change notifications.

        @param style: the style to set
        @type style:  L{pgm.widget.Style}
        """
        if self._style is not None:
            self._style.notifier.disconnect_by_func(self._style_properties_changed)

        if style is not None:
            style.notifier.connect('items-changed', self._style_properties_changed)
            self._style = style
            self.emit("style-set", style)
        else:
            self._style = None

    style = property(style__get, style__set)

    def _commit_style_update(self, properties):
        """
        Update the current style with properties, ensuring only the properties
        that need it are updated, and that nothing is updated when the widget
        is not visible.
        """
        if not self._initializing and self.is_mapped:
            new = set(properties.items())
            new.difference_update(set(self._committed_style.items()))
            if new != set([]):
                new = dict(new)
                self.update_style_properties(new)
                self._committed_style.update(new)

    def _style_properties_changed(self, notifier, style, new_properties):
        self._commit_style_update(new_properties)

    def do_style_set(self, style):
        """Default 'style-set' signal handler"""
        self._commit_style_update(style)

    def _parse_style_key(self, key, logstr):
        """
        Parse a style property key into a couple (widget, attribute name).

        The following keys will be parsed:
         * "attribute": C{self.attribute}
         * "subwidget-attribute": C{self.subwidget.attribute}
         * "subwidget-subwidget-attribute": C{self.subwidget.subwidget.attribute}
         * etc.

        @param key:    the key of the style property to parse
        @type key:     C{str}
        @param logstr: a base string to use when logging parsing errors
        @type logstr:  C{str}

        @raise AttributeError: when the key fails to be parsed

        @return: a tuple (widget, attribute name, enriched logging string)
        @rtype:  (L{Widget}, C{str}, C{str})
        """
        def get_subobject(obj, attribute):
            """
            Return object and attribute corresponding to L{obj}'s L{attribute}.
            A lookup in depth is performed, that is it will try to lookup
            attributes of attributes.

            @param obj: object for which we are looking up the attribute
            @type obj:  object
            @param attribute: attribute to look up; subattributes are separated
                              with '-'; example: "attribute-subattribute"
            @type attribute: string

            Code inspired from elisa.core.utils.bindable.get_sub_obj
            """
            path = attribute.split('-')
            while len(path) > 1:
                e = path.pop(0)
                obj = getattr(obj, e)
            return obj, path[0]

        try:
            logstr += ".%s" % key
            widget, attribute = get_subobject(self, key)
            try:
                getattr(widget, attribute)
            except AttributeError:
                # attribute was not found in the instance using the regular
                # resolution mechanism; it is the case with write-only properties
                # that we support here by trying to retrieve the descriptor on
                # the class itself
                getattr(type(widget), attribute)
        except AttributeError:
            log.warning('widget', '%s: attribute not found.' % logstr)
            raise

        return (widget, attribute, logstr)

    def _parse_style_value(self, value, widget, attribute, logstr):
        """
        Parse a style property value.

        Pigment properties will be transformed based on the type of the widget
        they apply to and the name of the attribute.

        @param value:     the value of the style property to parse
        @type value:      C{str}
        @param widget:    the widget the style property applies to
        @type widget:     L{Widget}
        @param attribute: the name of the attribute the style property applies
                          to
        @type attribute:  C{str}
        @param logstr:    a base string to use when logging parsing errors
        @type logstr:     C{str}

        @raise AttributeError: when the value fails to be parsed to a valid
                               pigment property

        @return: the value transformed (if relevant, untouched otherwise)
        """
        image_properties = ('alignment', 'layout', 'interp')
        text_properties = ('ellipsize', 'alignment', 'wrap', 'gravity',
                           'stretch', 'style', 'variant', 'weight',
                           'shadow_position')

        if isinstance(widget, pgm.Image) and attribute in image_properties:
                value = 'pgm.IMAGE_%s' % value.upper()
        elif isinstance(widget, pgm.Text) and attribute in text_properties:
            if attribute == 'alignment':
                value = 'pgm.TEXT_ALIGN_%s' % value.upper()
            elif attribute == 'shadow_position':
                value = 'pgm.TEXT_%s' % value.upper()
            else:
                value = 'pgm.TEXT_%s_%s' % (attribute.upper(), value.upper())
        else:
            return value

        logstr += '=%s' % value
        try:
            return eval(value)
        except (AttributeError, NameError):
            log.warning('widget', '%s: invalid value.' % logstr)
            raise

    def update_style_properties(self, props=None):
        """
        Update the widget's appearence basing on the properties set.

        By default all properties that can match a public member of the widget
        will be applied automatically. If custom style properties not matching
        real subwidgets/attributes need to be defined, this method should be
        overridden: the custom properties should be processed first, and then
        the remaining properties passed to the parent's update_style_properties
        method.

        @param props: the properties that have to be updated
        @type props: dictionary of strings ==> anything
        """
        if props is None:
            return

        base_logstr = \
            '%s.%s' % (self.__class__.__module__, self.__class__.__name__)

        for key, value in props.iteritems():
            try:
                widget, attribute, logstr = \
                    self._parse_style_key(key, base_logstr)
            except AttributeError:
                # Sub-widget or attribute not found, skip this property
                continue

            try:
                value = \
                    self._parse_style_value(value, widget, attribute, logstr)
            except AttributeError:
                # Invalid value, skip it
                continue

            if widget is self or not isinstance(widget, Widget):
                setattr(widget, attribute, value)
            else:
                widget.style[attribute] = value

    def get_parent(self):
        """
        Get the parent, if exists.

        @return: L{elisa.plugins.pigment.widgets.Widget}
        """
        return self.parent

    def get_children(self):
        """
        Get the list of direct children.

        @return: list of L{elisa.plugins.pigment.widgets.Widget}
        """
        return filter (lambda c: isinstance(c, Widget), self._children)

    def get_root(self):
        """
        Get the root of the widget's tree hierarchy

        @return: L{elisa.plugins.pigment.widgets.Widget}
        """
        while self.parent is not None:
            self = self.parent

        return self

    def get_descendants(self):
        """
        Get the list of nodes in the subtree

        @return: list of L{elisa.plugins.pigment.widgets.Widget}
        """
        children = filter (lambda c: isinstance(c, Widget), self._children)
        items = copy.copy(children)

        for child in children:
            items += child.get_descendants()

        return items

    def set_focus_proxy(self, proxy_widget):
        """
        DOCME
        """
        self._focus_proxy = proxy_widget

    def _accept_focus(self):
        """
        Unconditionally accept the focus.

        Change the widget that currently has the focus and update the
        focus property of all ancestors accordingly and where relevant.
        """
        previously_focused = Widget._focused

        # Nothing to do if the widget already had the focus
        if previously_focused is self:
            return

        Widget._focused = self

        ancestor = self
        while ancestor is not None and not ancestor._focus:
            ancestor._focus = True
            if ancestor.state != STATE_LOADING:
                # The 'loading' state has precedence over other states.
                ancestor.state = STATE_SELECTED
            ancestor.emit('focus', True)
            ancestor = ancestor.parent

        if previously_focused is None:
            return

        common_ancestor = ancestor
        ancestor = previously_focused
        while ancestor is not None and ancestor is not common_ancestor:
            ancestor._focus = False
            if ancestor.state != STATE_LOADING:
                # The 'loading' state has precedence over other states.
                ancestor.state = STATE_NORMAL
            ancestor.emit('focus', False)
            ancestor = ancestor.parent

    def _focus__get(self):
        return self._focus

    def _focus__set(self, focus):
        # Setting the focus this way is temporarily allowed for backward
        # compatibility. However it should be avoided. To transfer the focus to
        # a widget, use its set_focus method.
        if focus:
            self.set_focus()
        else:
            raise Exception("Setting focus to False is forbidden.")

    focus = property(_focus__get, _focus__set)

    def set_focus(self):
        """
        Pass the focused state to the widget.

        A widget accept the focus by default.
        This method may be overridden in specific widgets to implemented a
        different behaviour.

        When this method returns C{False}, the caller is notified that the
        focused widget hasn't changed.

        @return: C{True} if the widget or one of its descendants accepted the
                 focus, C{False} otherwise.
        @rtype:  C{bool}
        """
        if self._focus_proxy != None:
            if isinstance(self._focus_proxy, Widget):
                return self._focus_proxy.set_focus()
            else:
                return False

        self._accept_focus()
        return True

    def add_navigation_rule(self, start_widget, event_value, end_widget):
        """
        Add a specific navigation rule to the set of known rules.

        By default when the widget receives an input event, it tries to handle
        it doing a lookup in its navigation rules using the currently focused
        descendant widget and the value of the event.

        If the end widget is C{None}, no actual navigation will happen, but the
        event will be considered handled. Use this trick to swallow events.

        @param start_widget: a descendant widget
        @type start_widget:  L{elisa.plugins.pigment.widgets.widget.Widget}
        @param event_value:  the value of the input event
        @type event_value:   L{elisa.core.input_event.EventValue}
        @param end_widget:   a descendant widget or C{None}
        @type end_widget:    L{elisa.plugins.pigment.widgets.widget.Widget}

        @raise C{KeyError}: if there is already a known navigation rule for the
                            same start widget and event value
        """
        key = (start_widget, event_value)
        if self._navigation_rules.has_key(key):
            raise KeyError(key)
        self._navigation_rules[key] = end_widget

    def remove_navigation_rule(self, start_widget, event_value):
        """
        Remove an existing navigation rule from the set of known rules.

        @param start_widget: a descendant widget
        @type start_widget:  L{elisa.plugins.pigment.widgets.widget.Widget}
        @param event_value:  the value of the input event
        @type event_value:   L{elisa.core.input_event.EventValue}

        @raise C{KeyError}: if there is no known navigation rule for the given
                            start widget and event value
        """
        key = (start_widget, event_value)
        del self._navigation_rules[key]

    def _apply_navigation_rules(self, event_value):
        """
        Try and handle an input event by navigating in the descendant widgets.

        @param event_value: the value of the input event
        @type event_value:  L{elisa.core.input_event.EventValue}

        @return: C{True} is the event was handled, C{False} otherwise
        @rtype:  C{bool}
        """
        start_widget = None
        # start_widget is the child that passed the input event
        for child in self.get_children():
            if child.focus:
                start_widget = child
                break

        key = (start_widget, event_value)
        try:
            end_widget = self._navigation_rules[key]
        except KeyError:
            return False
        else:
            if end_widget is None:
                # No actual navigation, but swallow the event.
                return True
            end_widget.set_focus()
            return True

    def handle_input(self, manager, event):
        """
        Handle an Elisa input event.

        This method is meant to be called by the parent controller when the
        widget has the focus.
        The default implementation tries the known navigation rules and then
        passes the event to its parent.
        An event is considered handled when this method returns C{True}.

        Specific widgets may override this method to implement custom
        navigation.

        @param manager: the input manager that emitted the event
        @type manager:  L{elisa.core.input_manager.InputManager}
        @param event:   the input event received
        @type event:    L{elisa.core.input_event.InputEvent}

        @return:        C{True} is the event was handled, C{False} otherwise
        @rtype:         C{bool}
        """
        if self._apply_navigation_rules(event.value):
            return True

        if self.parent is None:
            return False
        else:
            return self.parent.handle_input(manager, event)

    def remove(self, child):
        super(Widget, self).remove(child)
        if child == self._focus_proxy:
            self._focus_proxy = None

    def clean(self):
        self._focus_proxy = None
        self.name = None
        self.style = None
        self._theme = None
        self.disconnect_by_func(self._mapped)
        return super(Widget, self).clean()

    @classmethod
    def _demo_create_viewport(cls, plugin):
        viewport = pgm.viewport_factory_make('opengl')
        viewport.title = cls.__name__
        return viewport

    @classmethod
    def _on_demo_delete(cls, viewport, event):
        try:
            __IPYTHON__
            sys.exit(0)
        except NameError:
            pgm.main_quit()

    @classmethod
    def _demo_widget(cls):
        """
        Meant to be overidden by inheriting widgets for widget creation and
        setup at demo time.

        @return: L{elisa.plugins.pigment.widgets.Widget}
        """
        widget = cls()
        return widget

    def _demo_handle_input(self, manager, event):
        # Specialized input handler for the demo mode.
        if event.value == EventValue.KEY_ESCAPE:
            viewport.emit('delete-event', None)
            return True

        # The widget may want to react upon pressing the f key (e.g. a text
        # entry widget)...
        if Widget._focused.handle_input(manager, event):
            return True

        # ... it did not, let's (un)fullscreen.
        if event.value == EventValue.KEY_f:
            viewport.set_fullscreen(not viewport.get_fullscreen())
            return True

        return False

    @classmethod
    def _set_demo_widget_defaults(cls, widget, canvas, viewport):
        widget.canvas = canvas

        from elisa.core.input_manager import InputManager
        widget.input_manager = InputManager()

        from elisa.plugins.pigment.pigment_input import PigmentInput
        widget.input = PigmentInput()
        widget.input.viewport = viewport
        widget.input_manager.register_component(widget.input)
        widget.input_manager.connect('input-event', widget._demo_handle_input)

        viewport.connect('delete-event', widget._on_demo_delete)

    @classmethod
    def demo(cls, plugin='opengl'):
        """
        Create a demo widget, put it on a canvas and show it in a viewport.

        Just start a pgm.main() or an "ipython -gthread" shell to interactively
        test your widget. See the __main__ block at the end of this file.

        @return: the demo L{elisa.plugins.pigment.widgets.Widget}
        """

        # viewport creation
        # keep a reference to the viewport in the global namespace so that it
        # does not get garbage collected
        global viewport
        viewport = cls._demo_create_viewport(plugin)

        # compute the aspect ratio
        w, h = viewport.screen_size_mm
        screen_aspect_ratio = h / float(w)

        # set the viewport aspect ratio to the screen physical aspect ratio
        viewport.height = viewport.width * screen_aspect_ratio

        # display the window
        viewport.show()

        # create and bind the canvas to the viewport
        canvas = pgm.Canvas()
        viewport.set_canvas(canvas)

        # width is set to 400.0 in order to make variations in depth
        # (z coordinate) visible
        canvas.width = 400.0
        # set the canvas aspect ratio to the screen physical aspect ratio
        canvas.height = canvas.width * screen_aspect_ratio

        # setup the animation system to update on sync during the demo
        def update_pass_callback(viewport, ticker):
            ticker.tick()

        ticker = Ticker()
        Animation.set_ticker(ticker)
        id = viewport.connect('update-pass', update_pass_callback, ticker)

        # create the widget for the demo and set it up
        widget = cls._demo_widget()
        widget.set_focus()
        widget.visible = True
        cls._set_demo_widget_defaults(widget, canvas, viewport)

        return widget


if __name__ == '__main__':
    logger = logging.getLogger()
    logger.setLevel(logging.DEBUG)

    widget = Widget.demo()
    try:
        __IPYTHON__
    except NameError:
        pgm.main()

