# HG changeset patch # User Goffi # Date 1474812416 -7200 # Node ID a51ea7874e43b714c858c715b0acdbc0c496c4b8 # Parent 817a45e6d7e3d94ed04b04d23efd1720a73e6cf0 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. diff -r 817a45e6d7e3 -r a51ea7874e43 src/cagou/plugins/plugin_wid_chat.kv --- 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 @@ : 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() : diff -r 817a45e6d7e3 -r a51ea7874e43 src/cagou/plugins/plugin_wid_chat.py --- 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: