Mercurial > libervia-desktop-kivy
view src/cagou/plugins/plugin_wid_chat.py @ 102:20251d926cc2
chat: fixed bad size_hint setting on XHTML
author | Goffi <goffi@goffi.org> |
---|---|
date | Thu, 05 Jan 2017 20:36:41 +0100 |
parents | d7447c585603 |
children | c601e3d40342 |
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.boxlayout import BoxLayout from kivy.uix.gridlayout import GridLayout from kivy.uix.stacklayout import StackLayout 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, css_color, strings as sat_strings from cagou.core import cagou_widget from cagou.core.image import Image, AsyncImage from cagou import G from xml.etree import ElementTree as ET import webbrowser 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_new_32.png", "icon_medium": u"{media}/icons/muchoslava/png/chat_new_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): def _addUrlMarkup(self, text): text_elts = [] idx = 0 links = 0 while True: m = sat_strings.RE_URL.search(text[idx:]) if m is not None: text_elts.append(escape_markup(m.string[0:m.start()])) link_key = u'link_' + unicode(links) url = m.group() text_elts.append(u'[color=5500ff][ref={link}]{url}[/ref][/color]'.format( link = link_key, url = url )) if not links: self.ref_urls = {link_key: url} else: self.ref_urls[link_key] = url links += 1 idx += m.end() else: if links: text_elts.append(escape_markup(text[idx:])) self.markup = True self.text = u''.join(text_elts) break def on_text(self, instance, text): # do NOT call the method if self.markup is set # this would result in infinite loop (because self.text # is changed if an URL is found, and in this case markup too) if text and not self.markup: self._addUrlMarkup(text) def on_ref_press(self, ref): url = self.ref_urls[ref] webbrowser.open(url) 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 MessageInputBox(BoxLayout): pass 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): message_input = properties.ObjectProperty() messages_widget = properties.ObjectProperty() 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) self.host.addListener('progressError', self.onProgressError, profiles) self.host.addListener('progressFinished', self.onProgressFinished, profiles) self._waiting_pids = {} # waiting progress ids 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 onProgressFinished(self, progress_id, metadata, profile): try: callback, cleaning_cb = self._waiting_pids.pop(progress_id) except KeyError: return if cleaning_cb is not None: cleaning_cb() callback(metadata, profile) def onProgressError(self, progress_id, err_msg, profile): try: dummy, cleaning_cb = self._waiting_pids[progress_id] except KeyError: return else: del self._waiting_pids[progress_id] if cleaning_cb is not None: cleaning_cb() # TODO: display message to user log.warning(u"Can't transfer file: {}".format(err_msg)) def fileTransferDone(self, metadata, profile): log.debug("file transfered: {}".format(metadata)) G.host.messageSend( self.target, {'': metadata['url']}, mess_type = C.MESS_TYPE_GROUPCHAT if self.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT, profile_key=profile ) def fileTransferCb(self, progress_data, cleaning_cb): try: progress_id = progress_data['progress'] except KeyError: xmlui = progress_data['xmlui'] G.host.showUI(xmlui) else: self._waiting_pids[progress_id] = (self.fileTransferDone, cleaning_cb) def onTransferOK(self, file_path, cleaning_cb, transfer_type): if transfer_type == C.TRANSFER_UPLOAD: G.host.bridge.fileUpload( file_path, "", "", {"ignore_tls_errors": C.BOOL_TRUE}, # FIXME: should not be the default self.profile, callback = lambda progress_data: self.fileTransferCb(progress_data, cleaning_cb) ) elif transfer_type == C.TRANSFER_SEND: if self.type == C.CHAT_GROUP: log.warning(u"P2P transfer is not possible for group chat") # TODO: show an error dialog to user, or better hide the send button for MUC else: jid_ = self.target if not jid_.resource: jid_ = G.host.contact_lists[self.profile].getFullJid(jid_) G.host.bridge.fileSend(jid_, file_path, "", "", profile=self.profile) # TODO: notification of sending/failing else: raise log.error(u"transfer of type {} are not handled".format(transfer_type)) 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): self.host.removeListener('progressFinished', self.onProgressFinished) self.host.removeListener('progressError', self.onProgressError) return super(Chat, self).onDelete() def onDelete(self, force=False): if force==True: return self._onDelete() if len(list(G.host.widgets.getWidgets(self.__class__, self.target, profiles=self.profiles))) > 1: # we don't keep duplicate widgets return self._onDelete() return False PLUGIN_INFO["factory"] = Chat.factory quick_widgets.register(quick_chat.QuickChat, Chat)