changeset 57:a51ea7874e43

chat: XHTML parsing first draft: a XHTML mini parser is used so rich text can be handled. For now it manages only a few basic elements (bold, italic, color). Body sizing/alignment is currenly broken.
author Goffi <goffi@goffi.org>
date Sun, 25 Sep 2016 16:06:56 +0200
parents 817a45e6d7e3
children 7aa2ffff9067
files src/cagou/plugins/plugin_wid_chat.kv src/cagou/plugins/plugin_wid_chat.py
diffstat 2 files changed, 254 insertions(+), 22 deletions(-) [+]
line wrap: on
line diff
--- a/src/cagou/plugins/plugin_wid_chat.kv	Fri Sep 16 11:49:36 2016 +0200
+++ b/src/cagou/plugins/plugin_wid_chat.kv	Sun Sep 25 16:06:56 2016 +0200
@@ -28,7 +28,7 @@
 
 <MessageWidget>:
     cols: 1
-    mess_label: mess_label
+    mess_xhtml: mess_xhtml
     padding: dp(10)
     spacing: dp(5)
     size_hint: 1, None
@@ -52,9 +52,9 @@
     BoxLayout:
         # BoxLayout is needed here, else GridLayout won't let the Label choose its width
         size_hint: 1, None
-        height: mess_label.height
+        height: mess_xhtml.height
         on_size: root.widthAdjust()
-        Label:
+        SimpleXHTMLWidget:
             canvas.before:
                 Color:
                     rgba: 1, 1, 1, 1
@@ -62,15 +62,11 @@
                     source: app.expand("{media}/misc/black.png") if root.mess_data.type == "info" else app.expand("{media}/misc/borders/{}.jpg", "blue" if root.mess_data.own_mess else "gray")
                     pos: self.pos
                     size: self.size
-            id: mess_label
+            id: mess_xhtml
+            xhtml: root.message_xhtml or self.escape(root.message or u' ')
             color: (0.74,0.74,0.24,1) if root.mess_data.type == "info" else (0, 0, 0, 1)
             padding: root.mess_padding
-            text_size: None, None
-            size_hint: None, None
-            size: self.texture_size
             bold: True if root.mess_data.type == "info" else False
-            text: root.message or u'  '
-            # on_texture_size: root.widthAdjust()
 
 
 <MessageInputWidget>:
--- a/src/cagou/plugins/plugin_wid_chat.py	Fri Sep 16 11:49:36 2016 +0200
+++ b/src/cagou/plugins/plugin_wid_chat.py	Sun Sep 25 16:06:56 2016 +0200
@@ -23,16 +23,20 @@
 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.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
+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 = {
@@ -48,9 +52,235 @@
     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 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)
+
+    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 = Label(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)
+
+    # 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
+        """
+        if append_to_list:
+            self.styles.append((tag, value))
+        label = self._getLabel()
+        label.text += u'[{tag}{value}]'.format(
+            tag = tag,
+            value = u'={}'.format(value) if value else ''
+            )
+
+    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()
+        label = Label(color=self.color, markup=True)
+        self.current_wid = label
+        self.bind(color=self.current_wid.setter('color'))
+        label.bind(texture_size=label.setter('size'))
+        for tag, value in self.styles:
+            self._addStyle(tag, value, append_to_list=False)
+        self.add_widget(self.current_wid)
+
+    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_p(self, elem):
+        self._addLabel()
+        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_label = properties.ObjectProperty()
+    mess_xhtml = properties.ObjectProperty()
     mess_padding = (dp(5), dp(5))
     avatar = properties.ObjectProperty()
 
@@ -68,19 +298,25 @@
         """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"""
-        parent = self.mess_label.parent
-        padding_x = self.mess_padding[0]
-        text_width, text_height = self.mess_label.texture_size
-        if text_width > parent.width:
-            self.mess_label.text_size = (parent.width - padding_x, None)
-            self.text_max = text_width
-        elif self.mess_label.text_size[0] is not None and text_width  < parent.width - padding_x:
-            if text_width < self.text_max:
-                self.mess_label.text_size = (None, None)
-            else:
-                self.mess_label.text_size = (parent.width  - padding_x, None)
+        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: