view src/cagou/plugins/plugin_wid_chat.py @ 68:4a1e1012337e

chat: fixed chat messages width
author Goffi <goffi@goffi.org>
date Sat, 17 Dec 2016 14:34:45 +0100
parents 5f7f72c2635f
children 46d962910801
line wrap: on
line source

#!/usr/bin/python
# -*- coding: utf-8 -*-

# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
# Copyright (C) 2016 Jérôme Poisson (goffi@goffi.org)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 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 Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.


from sat.core import log as logging
log = logging.getLogger(__name__)
from sat.core.i18n import _
from cagou.core.constants import Const as C
from kivy.uix.gridlayout import GridLayout
from kivy.uix.stacklayout import StackLayout
from kivy.uix.scrollview import ScrollView
from kivy.uix.textinput import TextInput
from kivy.uix.label import Label
from kivy.uix.image import AsyncImage
from kivy.metrics import dp
from kivy.utils import escape_markup
from kivy import properties
from sat_frontends.quick_frontend import quick_widgets
from sat_frontends.quick_frontend import quick_chat
from sat_frontends.tools import jid, css_color
from cagou.core import cagou_widget
from cagou.core.image import Image
from cagou import G
from xml.etree import ElementTree as ET


PLUGIN_INFO = {
    "name": _(u"chat"),
    "main": "Chat",
    "description": _(u"instant messaging with one person or a group"),
    "icon_small": u"{media}/icons/muchoslava/png/chat_rouge_32.png",
    "icon_medium": u"{media}/icons/muchoslava/png/chat_rouge_44.png"
}


class MessAvatar(Image):
    pass


class Escape(unicode):
    """Class used to mark that a message need to be escaped"""

    def __init__(self, text):
        super(Escape, self).__init__(text)


class SimpleXHTMLWidgetEscapedText(Label):
    pass


class SimpleXHTMLWidgetText(Label):
    pass


class SimpleXHTMLWidgetImage(AsyncImage):
    # following properties are desired height/width
    # i.e. the ones specified in height/width attributes of <img>
    # (or wanted for whatever reason)
    # set to 0 to ignore them
    target_height = properties.NumericProperty()
    target_width = properties.NumericProperty()

    def _get_parent_container(self):
        """get parent SimpleXHTMLWidget instance

        @param warning(bool): if True display a log.error if nothing found
        @return (SimpleXHTMLWidget, None): found SimpleXHTMLWidget instance
        """
        parent = self.parent
        while parent and not isinstance(parent, SimpleXHTMLWidget):
            parent = parent.parent
        if parent is None:
            log.error(u"no SimpleXHTMLWidget parent found")
        return parent

    def _on_source_load(self, value):
        # this method is called when image is loaded
        super(SimpleXHTMLWidgetImage, self)._on_source_load(value)
        if self.parent is not None:
            container = self._get_parent_container()
            # image is loaded, we need to recalculate size
            self.on_container_width(container, container.width)

    def on_container_width(self, container, container_width):
        """adapt size according to container width

        called when parent container (SimpleXHTMLWidget) width change
        """
        target_size = (self.target_width or self.texture.width, self.target_height or self.texture.height)
        padding = container.padding
        padding_h = (padding[0] + padding[2]) if len(padding) == 4 else padding[0]
        width = container_width - padding_h
        if target_size[0] < width:
            self.size = target_size
        else:
            height = width / self.image_ratio
            self.size = (width, height)

    def on_parent(self, instance, parent):
        if parent is not None:
            container = self._get_parent_container()
            container.bind(width=self.on_container_width)


class SimpleXHTMLWidget(StackLayout):
    """widget handling simple XHTML parsing"""
    xhtml = properties.StringProperty()
    color = properties.ListProperty([1, 1, 1, 1])
    # XXX: bold is only used for escaped text
    bold = properties.BooleanProperty(False)
    content_width = properties.NumericProperty(0)

    # text/XHTML input

    def on_xhtml(self, instance, xhtml):
        """parse xhtml and set content accordingly

        if xhtml is an instance of Escape, a Label with not markup
        will be used
        """
        self.clear_widgets()
        if isinstance(xhtml, Escape):
            label = SimpleXHTMLWidgetEscapedText(text=xhtml, color=self.color)
            self.bind(color=label.setter('color'))
            self.bind(bold=label.setter('bold'))
            self.add_widget(label)
        else:
            xhtml = ET.fromstring(xhtml.encode('utf-8'))
            self.current_wid = None
            self.styles = []
            self._callParseMethod(xhtml)

    def escape(self, text):
        """mark that a text need to be escaped (i.e. no markup)"""
        return Escape(text)

    # sizing

    def on_width(self, instance, width):
        if len(self.children) == 1:
            wid = self.children[0]
            if isinstance(wid, Label):
                # we have simple text
                try:
                    full_width = wid._full_width
                except AttributeError:
                    # on first time, we need the required size
                    # for the full text, without width limit
                    wid.size_hint = (None, None)
                    wid.texture_update()
                    full_width = wid._full_width = wid.texture_size[0]

                if full_width > width:
                    wid.text_size = width, None
                    wid.width = width
                else:
                    wid.text_size = None, None
                    wid.texture_update()
                    wid.width = wid.texture_size[0]
                self.content_width = wid.width + self.padding[0] + self.padding[2]
            else:
                wid.size_hint(1, None)
                wid.height = 100
                self.content_width = self.width
        else:
            self._do_complexe_sizing(width)

    def _do_complexe_sizing(self, width):
        try:
            self.splitted
        except AttributeError:
            # XXX: to make things easier, we split labels in words
            log.debug(u"split start")
            children = self.children[::-1]
            self.clear_widgets()
            for child in children:
                if isinstance(child, Label):
                    log.debug(u"label before split: {}".format(child.text))
                    styles = []
                    tag = False
                    new_text = []
                    current_tag = []
                    current_value = []
                    current_wid = self._createText()
                    value = False
                    close = False
                    # we will parse the text and create a new widget
                    # on each new word (actually each space)
                    # FIXME: handle '\n' and other white chars
                    for c in child.text:
                        if tag:
                            # we are parsing a markup tag
                            if c == u']':
                                current_tag_s = u''.join(current_tag)
                                current_style = (current_tag_s, u''.join(current_value))
                                if close:
                                    for idx, s in enumerate(reversed(styles)):
                                        if s[0] == current_tag_s:
                                            del styles[len(styles) - idx - 1]
                                            break
                                else:
                                    styles.append(current_style)
                                current_tag = []
                                current_value = []
                                tag = False
                                value = False
                                close = False
                            elif c == u'/':
                                close = True
                            elif c == u'=':
                                value = True
                            elif value:
                                current_value.append(c)
                            else:
                                current_tag.append(c)
                            new_text.append(c)
                        else:
                            # we are parsing regular text
                            if c == u'[':
                                new_text.append(c)
                                tag = True
                            elif c == u' ':
                                # new word, we do a new widget
                                new_text.append(u' ')
                                for t, v in reversed(styles):
                                    new_text.append(u'[/{}]'.format(t))
                                current_wid.text = u''.join(new_text)
                                new_text = []
                                self.add_widget(current_wid)
                                log.debug(u"new widget: {}".format(current_wid.text))
                                current_wid = self._createText()
                                for t, v in styles:
                                    new_text.append(u'[{tag}{value}]'.format(
                                        tag = t,
                                        value = u'={}'.format(v) if v else u''))
                            else:
                                new_text.append(c)
                    if current_wid.text:
                        # we may have a remaining widget after the parsing
                        close_styles = []
                        for t, v in reversed(styles):
                            close_styles.append(u'[/{}]'.format(t))
                        current_wid.text = u''.join(close_styles)
                        self.add_widget(current_wid)
                        log.debug(u"new widget: {}".format(current_wid.text))
                else:
                    # non Label widgets, we just add them
                    self.add_widget(child)
            self.splitted = True
            log.debug(u"split OK")

        # we now set the content width
        # FIXME: for now we just use the full width
        self.content_width = width

    # XHTML parsing methods

    def _callParseMethod(self, e):
        """call the suitable method to parse the element

        self.xhtml_[tag] will be called if it exists, else
        self.xhtml_generic will be used
        @param e(ET.Element): element to parse
        """
        try:
            method = getattr(self, "xhtml_{}".format(e.tag))
        except AttributeError:
            log.warning(u"Unhandled XHTML tag: {}".format(e.tag))
            method = self.xhtml_generic
        method(e)

    def _addStyle(self, tag, value=None, append_to_list=True):
        """add a markup style to label

        @param tag(unicode): markup tag
        @param value(unicode): markup value if suitable
        @param append_to_list(bool): if True style we be added to self.styles
            self.styles is needed to keep track of styles to remove
            should most probably be set to True
        """
        label = self._getLabel()
        label.text += u'[{tag}{value}]'.format(
            tag = tag,
            value = u'={}'.format(value) if value else ''
            )
        if append_to_list:
            self.styles.append((tag, value))

    def _removeStyle(self, tag, remove_from_list=True):
        """remove a markup style from the label

        @param tag(unicode): markup tag to remove
        @param remove_from_list(bool): if True, remove from self.styles too
            should most probably be set to True
        """
        label = self._getLabel()
        label.text += u'[/{tag}]'.format(
            tag = tag
            )
        if remove_from_list:
            for rev_idx, style in enumerate(reversed(self.styles)):
                if style[0] == tag:
                    tag_idx = len(self.styles) - 1 - rev_idx
                    del self.styles[tag_idx]
                    break

    def _getLabel(self):
        """get current Label if it exists, or create a new one"""
        if not isinstance(self.current_wid, Label):
            self._addLabel()
        return self.current_wid

    def _addLabel(self):
        """add a new Label

        current styles will be closed and reopened if needed
        """
        self._closeLabel()
        self.current_wid = self._createText()
        for tag, value in self.styles:
            self._addStyle(tag, value, append_to_list=False)
        self.add_widget(self.current_wid)

    def _createText(self):
        label = SimpleXHTMLWidgetText(color=self.color, markup=True)
        self.bind(color=label.setter('color'))
        label.bind(texture_size=label.setter('size'))
        return label

    def _closeLabel(self):
        """close current style tags in current label

        needed when you change label to keep style between
        different widgets
        """
        if isinstance(self.current_wid, Label):
            for tag, value in reversed(self.styles):
                self._removeStyle(tag, remove_from_list=False)

    def _parseCSS(self, e):
        """parse CSS found in "style" attribute of element

        self._css_styles will be created and contained markup styles added by this method
        @param e(ET.Element): element which may have a "style" attribute
        """
        styles_limit = len(self.styles)
        styles = e.attrib['style'].split(u';')
        for style in styles:
            try:
                prop, value = style.split(u':')
            except ValueError:
                log.warning(u"can't parse style: {}".format(style))
                continue
            prop = prop.strip().replace(u'-', '_')
            value = value.strip()
            try:
                method = getattr(self, "css_{}".format(prop))
            except AttributeError:
                log.warning(u"Unhandled CSS: {}".format(prop))
            else:
                method(e, value)
        self._css_styles = self.styles[styles_limit:]

    def _closeCSS(self):
        """removed CSS styles

        styles in self._css_styles will be removed
        and the attribute will be deleted
        """
        for tag, dummy in reversed(self._css_styles):
            self._removeStyle(tag)
        del self._css_styles

    def xhtml_generic(self, elem, style=True, markup=None):
        """generic method for adding HTML elements

        this method handle content, style and children parsing
        @param elem(ET.Element): element to add
        @param style(bool): if True handle style attribute (CSS)
        @param markup(tuple[unicode, (unicode, None)], None): kivy markup to use
        """
        # we first add markup and CSS style
        if markup is not None:
            if isinstance(markup, basestring):
                tag, value = markup, None
            else:
                tag, value = markup
            self._addStyle(tag, value)
        style_ = 'style' in elem.attrib and style
        if style_:
            self._parseCSS(elem)

        # then content
        if elem.text:
            self._getLabel().text += escape_markup(elem.text)

        # we parse the children
        for child in elem:
            self._callParseMethod(child)

        # closing CSS style and markup
        if style_:
            self._closeCSS()
        if markup is not None:
            self._removeStyle(tag)

        # and the tail, which is regular text
        if elem.tail:
            self._getLabel().text += escape_markup(elem.tail)

    # method handling XHTML elements

    def xhtml_br(self, elem):
        label = self._getLabel()
        label.text+='\n'
        self.xhtml_generic(style=False)

    def xhtml_em(self, elem):
        self.xhtml_generic(elem, markup='i')

    def xhtml_img(self, elem):
        try:
            src = elem.attrib['src']
        except KeyError:
            log.warning(u"<img> element without src: {}".format(ET.tostring(elem)))
            return
        try:
            target_height = int(elem.get(u'height', 0))
        except ValueError:
            log.warning(u"Can't parse image height: {}".format(elem.get(u'height')))
            target_height = 0
        try:
            target_width = int(elem.get(u'width', 0))
        except ValueError:
            log.warning(u"Can't parse image width: {}".format(elem.get(u'width')))
            target_width = 0

        img = SimpleXHTMLWidgetImage(source=src, target_height=target_height, target_width=target_width)
        self.current_wid = img
        self.add_widget(img)

    def xhtml_p(self, elem):
        if isinstance(self.current_wid, Label):
            self.current_wid.text+="\n\n"
        self.xhtml_generic(elem)

    def xhtml_span(self, elem):
        self.xhtml_generic(elem)

    def xhtml_strong(self, elem):
        self.xhtml_generic(elem, markup='b')

    # methods handling CSS properties

    def css_color(self, elem, value):
        self._addStyle(u"color", css_color.parse(value))

    def css_text_decoration(self, elem, value):
        if value == u'underline':
            log.warning(u"{} not handled yet, it needs Kivy 1.9.2 to be released".format(value))
            # FIXME: activate when 1.9.2 is out
            # self._addStyle('u')
        elif value == u'line-through':
            log.warning(u"{} not handled yet, it needs Kivy 1.9.2 to be released".format(value))
            # FIXME: activate when 1.9.2 is out
            # self._addStyle('s')
        else:
            log.warning(u"unhandled text decoration: {}".format(value))


class MessageWidget(GridLayout):
    mess_data = properties.ObjectProperty()
    mess_xhtml = properties.ObjectProperty()
    mess_padding = (dp(5), dp(5))
    avatar = properties.ObjectProperty()

    def __init__(self, **kwargs):
        super(MessageWidget, self).__init__(**kwargs)
        self.mess_data.widgets.add(self)

    @property
    def chat(self):
        """return parent Chat instance"""
        return self.mess_data.parent

    @property
    def message(self):
        """Return currently displayed message"""
        return self.mess_data.main_message

    @property
    def message_xhtml(self):
        """Return currently displayed message"""
        return self.mess_data.main_message_xhtml

    def widthAdjust(self):
        """this widget grows up with its children"""
        pass
        # parent = self.mess_xhtml.parent
        # padding_x = self.mess_padding[0]
        # text_width, text_height = self.mess_xhtml.texture_size
        # if text_width > parent.width:
        #     self.mess_xhtml.text_size = (parent.width - padding_x, None)
        #     self.text_max = text_width
        # elif self.mess_xhtml.text_size[0] is not None and text_width  < parent.width - padding_x:
        #     if text_width < self.text_max:
        #         self.mess_xhtml.text_size = (None, None)
        #     else:
        #         self.mess_xhtml.text_size = (parent.width  - padding_x, None)

    def update(self, update_dict):
        if 'avatar' in update_dict:
            self.avatar.source = update_dict['avatar']


class MessageInputWidget(TextInput):

    def _key_down(self, key, repeat=False):
        displayed_str, internal_str, internal_action, scale = key
        if internal_action == 'enter':
            self.dispatch('on_text_validate')
        else:
            super(MessageInputWidget, self)._key_down(key, repeat)


class MessagesWidget(GridLayout):
    pass


class Chat(quick_chat.QuickChat, cagou_widget.CagouWidget):

    def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None, subject=None, profiles=None):
        quick_chat.QuickChat.__init__(self, host, target, type_, nick, occupants, subject, profiles=profiles)
        cagou_widget.CagouWidget.__init__(self)
        self.header_input.hint_text = u"{}".format(target)
        scroll_view = ScrollView(size_hint=(1,0.8), scroll_y=0, do_scroll_x=False)
        self.messages_widget = MessagesWidget()
        scroll_view.add_widget(self.messages_widget)
        self.add_widget(scroll_view)
        message_input = MessageInputWidget()
        message_input.bind(on_text_validate=self.onSend)
        self.add_widget(message_input)
        self.postInit()

    @classmethod
    def factory(cls, plugin_info, target, profiles):
        profiles = list(profiles)
        if len(profiles) > 1:
            raise NotImplementedError(u"Multi-profiles is not available yet for chat")
        if target is None:
            target = G.host.profiles[profiles[0]].whoami
        return G.host.widgets.getOrCreateWidget(cls, target, on_new_widget=None, on_existing_widget=C.WIDGET_RECREATE, profiles=profiles)

    def messageDataConverter(self, idx, mess_id):
        return {"mess_data": self.messages[mess_id]}

    def _onHistoryPrinted(self):
        """Refresh or scroll down the focus after the history is printed"""
        # self.adapter.data = self.messages
        for mess_data in self.messages.itervalues():
            self.appendMessage(mess_data)
        super(Chat, self)._onHistoryPrinted()

    def createMessage(self, message):
        self.appendMessage(message)

    def appendMessage(self, mess_data):
        self.messages_widget.add_widget(MessageWidget(mess_data=mess_data))

    def onSend(self, input_widget):
        G.host.messageSend(
            self.target,
            {'': input_widget.text}, # TODO: handle language
            mess_type = C.MESS_TYPE_GROUPCHAT if self.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT, # TODO: put this in QuickChat
            profile_key=self.profile
            )
        input_widget.text = ''

    def _mucJoinCb(self, joined_data):
        joined, room_jid_s, occupants, user_nick, subject, profile = joined_data
        self.host.mucRoomJoinedHandler(*joined_data[1:])
        jid_ = jid.JID(room_jid_s)
        self.changeWidget(jid_)

    def _mucJoinEb(self, failure):
        log.warning(u"Can't join room: {}".format(failure))

    def changeWidget(self, jid_):
        """change current widget for a new one with given jid

        @param jid_(jid.JID): jid of the widget to create
        """
        plugin_info = G.host.getPluginInfo(main=Chat)
        factory = plugin_info['factory']
        G.host.switchWidget(self, factory(plugin_info, jid_, profiles=[self.profile]))
        self.header_input.text = ''

    def onHeaderInput(self):
        text = self.header_input.text.strip()
        try:
            if text.count(u'@') != 1 or text.count(u' '):
                raise ValueError
            jid_ = jid.JID(text)
        except ValueError:
            log.info(u"entered text is not a jid")
            return

        def discoCb(disco):
            # TODO: check if plugin XEP-0045 is activated
            if "conference" in [i[0] for i in disco[1]]:
                G.host.bridge.mucJoin(unicode(jid_), "", "", self.profile, callback=self._mucJoinCb, errback=self._mucJoinEb)
            else:
                self.changeWidget(jid_)

        def discoEb(failure):
            log.warning(u"Disco failure, ignore this text: {}".format(failure))

        G.host.bridge.discoInfos(jid_.domain, self.profile, callback=discoCb, errback=discoEb)

    def onDelete(self, force=False):
        if force==True:
            return super(Chat, self).onDelete()
        if len(list(G.host.widgets.getWidgets(self.__class__, self.target, profiles=self.profiles))) > 1:
            # we don't keep duplicate widgets
            return super(Chat, self).onDelete()
        return False


PLUGIN_INFO["factory"] = Chat.factory
quick_widgets.register(quick_chat.QuickChat, Chat)