# HG changeset patch # User Goffi # Date 1575635131 -3600 # Node ID 5868a5575e01460bdade39e11d2bb9ae3d0874a1 # Parent 4374cb741eb50bd8a8bc0df6b3ecbd58edd6533a chat: cleaning + some improvments: - code cleaning, removed some dead code - some improvments on the way size is calculated, removed unnecessary sizing methods which were linked to properties - image have now a max size, this avoid gigantic image in the whole screen - in SimpleXHTMLWidget, Label are now splitted when xhtml is set - use a DelayedBoxLayout for messages, as they are really slow to be resized - use of RecycleView has been investigated, but it is not currently usable as dynamic contents are not propertly handled (see https://github.com/kivy/kivy/issues/6580 and https://github.com/kivy/kivy/issues/6582). Furthermore, some tests with RecycleView on Android don't give the expected speed boost, so BoxLayout still seems like the way to go for the moment. To be re-investigated at a later point if necessary. diff -r 4374cb741eb5 -r 5868a5575e01 cagou/core/constants.py --- a/cagou/core/constants.py Fri Dec 06 13:23:03 2019 +0100 +++ b/cagou/core/constants.py Fri Dec 06 13:25:31 2019 +0100 @@ -52,3 +52,7 @@ COLOR_ERROR = (1.0, 0.0, 0.0, 1) COLOR_BTN_LIGHT = (0.4, 0.4, 0.4, 1) + + # values are in dp + IMG_MAX_WIDTH = 500 + IMG_MAX_HEIGHT = 500 diff -r 4374cb741eb5 -r 5868a5575e01 cagou/core/simple_xhtml.py --- a/cagou/core/simple_xhtml.py Fri Dec 06 13:23:03 2019 +0100 +++ b/cagou/core/simple_xhtml.py Fri Dec 06 13:25:31 2019 +0100 @@ -18,17 +18,20 @@ # along with this program. If not, see . -from sat.core import log as logging -log = logging.getLogger(__name__) +import webbrowser +from xml.etree import ElementTree as ET from kivy.uix.stacklayout import StackLayout from kivy.uix.label import Label from kivy.utils import escape_markup -from kivy.metrics import sp +from kivy.metrics import sp, dp from kivy import properties -from xml.etree import ElementTree as ET +from sat.core import log as logging from sat_frontends.tools import css_color, strings as sat_strings from cagou.core.image import AsyncImage -import webbrowser +from cagou.core.constants import Const as C + + +log = logging.getLogger(__name__) class Escape(str): @@ -51,10 +54,9 @@ text_elts.append(escape_markup(m.string[0:m.start()])) link_key = 'link_' + str(links) url = m.group() - text_elts.append('[color=5500ff][ref={link}]{url}[/ref][/color]'.format( - link = link_key, - url = url - )) + escaped_url = escape_markup(url) + text_elts.append( + f'[color=5500ff][ref={link_key}]{escaped_url}[/ref][/color]') if not links: self.ref_urls = {link_key: url} else: @@ -83,57 +85,55 @@ class SimpleXHTMLWidgetText(Label): def on_parent(self, instance, parent): - self.font_size = parent.font_size + if parent is not None: + self.font_size = parent.font_size class SimpleXHTMLWidgetImage(AsyncImage): # following properties are desired height/width # i.e. the ones specified in height/width attributes of # (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 + # set to None to ignore them + target_height = properties.NumericProperty(allownone=True) + target_width = properties.NumericProperty(allownone=True) - @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("no SimpleXHTMLWidget parent found") - return parent + def __init__(self, **kwargs): + # best calculated size + self._best_width = self._best_height = 100 + super().__init__(**kwargs) - 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 + def on_texture(self, instance, texture): + """Adapt the size according to max size and target_*""" + if texture is None: + return + max_width, max_height = dp(C.IMG_MAX_WIDTH), dp(C.IMG_MAX_HEIGHT) + width, height = texture.size + if self.target_width: + width = min(width, self.target_width) + if width > max_width: + width = C.IMG_MAX_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) + height = width / self.image_ratio + + if self.target_height: + height = min(height, self.target_height) + + if height > max_height: + height = max_height + width = height * self.image_ratio + + self.width, self.height = self._best_width, self._best_height = 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) + parent.bind(width=self.on_parent_width) + + def on_parent_width(self, instance, width): + if self._best_width > width: + self.width = width + self.height = width / self.image_ratio + else: + self.width, self.height = self._best_width, self._best_height class SimpleXHTMLWidget(StackLayout): @@ -142,7 +142,6 @@ color = properties.ListProperty([1, 1, 1, 1]) # XXX: bold is only used for escaped text bold = properties.BooleanProperty(False) - content_width = properties.NumericProperty(0) font_size = properties.NumericProperty(sp(14)) # text/XHTML input @@ -156,151 +155,117 @@ if isinstance(xhtml, Escape): label = SimpleXHTMLWidgetEscapedText( text=xhtml, color=self.color, bold=self.bold) + self.bind(font_size=label.setter('font_size')) self.bind(color=label.setter('color')) self.bind(bold=label.setter('bold')) self.add_widget(label) else: - xhtml = ET.fromstring(xhtml.encode('utf-8')) + xhtml = ET.fromstring(xhtml.encode()) self.current_wid = None self.styles = [] self._callParseMethod(xhtml) + if len(self.children) > 1: + self._do_split_labels() 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] + def _do_split_labels(self): + """Split labels so their content can flow with images""" + # XXX: to make things easier, we split labels in words + log.debug("labels splitting start") + children = self.children[::-1] + self.clear_widgets() + for child in children: + if isinstance(child, Label): + log.debug("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 == ']': + current_tag_s = ''.join(current_tag) + current_style = (current_tag_s, ''.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 == '/': + close = True + elif c == '=': + 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 == '[': + new_text.append(c) + tag = True + elif c == ' ': + # new word, we do a new widget + new_text.append(' ') + for t, v in reversed(styles): + new_text.append('[/{}]'.format(t)) + current_wid.text = ''.join(new_text) + new_text = [] + self.add_widget(current_wid) + log.debug("new widget: {}".format(current_wid.text)) + current_wid = self._createText() + for t, v in styles: + new_text.append('[{tag}{value}]'.format( + tag = t, + value = '={}'.format(v) if v else '')) + 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('[/{}]'.format(t)) + current_wid.text = ''.join(close_styles) + self.add_widget(current_wid) + log.debug("new widget: {}".format(current_wid.text)) 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("split start") - children = self.children[::-1] - self.clear_widgets() - for child in children: - if isinstance(child, Label): - log.debug("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 == ']': - current_tag_s = ''.join(current_tag) - current_style = (current_tag_s, ''.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 == '/': - close = True - elif c == '=': - 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 == '[': - new_text.append(c) - tag = True - elif c == ' ': - # new word, we do a new widget - new_text.append(' ') - for t, v in reversed(styles): - new_text.append('[/{}]'.format(t)) - current_wid.text = ''.join(new_text) - new_text = [] - self.add_widget(current_wid) - log.debug("new widget: {}".format(current_wid.text)) - current_wid = self._createText() - for t, v in styles: - new_text.append('[{tag}{value}]'.format( - tag = t, - value = '={}'.format(v) if v else '')) - 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('[/{}]'.format(t)) - current_wid.text = ''.join(close_styles) - self.add_widget(current_wid) - log.debug("new widget: {}".format(current_wid.text)) - else: - # non Label widgets, we just add them - self.add_widget(child) - self.splitted = True - log.debug("split OK") - - # we now set the content width - # FIXME: for now we just use the full width - self.content_width = width + # non Label widgets, we just add them + self.add_widget(child) + self.splitted = True + log.debug("split OK") # XHTML parsing methods def _callParseMethod(self, e): - """call the suitable method to parse the element + """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)) + method = getattr(self, f"xhtml_{e.tag}") except AttributeError: - log.warning("Unhandled XHTML tag: {}".format(e.tag)) + log.warning(f"Unhandled XHTML tag: {e.tag}") method = self.xhtml_generic method(e) @@ -384,14 +349,14 @@ try: prop, value = style.split(':') except ValueError: - log.warning("can't parse style: {}".format(style)) + log.warning(f"can't parse style: {style}") continue prop = prop.strip().replace('-', '_') value = value.strip() try: - method = getattr(self, "css_{}".format(prop)) + method = getattr(self, f"css_{prop}") except AttributeError: - log.warning("Unhandled CSS: {}".format(prop)) + log.warning(f"Unhandled CSS: {prop}") else: method(e, value) self._css_styles = self.styles[styles_limit:] @@ -407,7 +372,7 @@ del self._css_styles def xhtml_generic(self, elem, style=True, markup=None): - """generic method for adding HTML elements + """Generic method for adding HTML elements this method handle content, style and children parsing @param elem(ET.Element): element to add @@ -462,13 +427,13 @@ try: target_height = int(elem.get('height', 0)) except ValueError: - log.warning("Can't parse image height: {}".format(elem.get('height'))) - target_height = 0 + log.warning(f"Can't parse image height: {elem.get('height')}") + target_height = None try: target_width = int(elem.get('width', 0)) except ValueError: - log.warning("Can't parse image width: {}".format(elem.get('width'))) - target_width = 0 + log.warning(f"Can't parse image width: {elem.get('width')}") + target_width = None img = SimpleXHTMLWidgetImage(source=src, target_height=target_height, target_width=target_width) self.current_wid = img diff -r 4374cb741eb5 -r 5868a5575e01 cagou/kv/simple_xhtml.kv --- a/cagou/kv/simple_xhtml.kv Fri Dec 06 13:23:03 2019 +0100 +++ b/cagou/kv/simple_xhtml.kv Fri Dec 06 13:25:31 2019 +0100 @@ -14,10 +14,17 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +#:import C cagou.core.constants.Const + + +: + size_hint: 1, None + height: self.minimum_height : - size_hint: None, None - size: self.texture_size + size_hint: 1, None + text_size: self.width, None + height: self.texture_size[1] : size_hint: None, None diff -r 4374cb741eb5 -r 5868a5575e01 cagou/plugins/plugin_wid_chat.kv --- a/cagou/plugins/plugin_wid_chat.kv Fri Dec 06 13:23:03 2019 +0100 +++ b/cagou/plugins/plugin_wid_chat.kv Fri Dec 06 13:25:31 2019 +0100 @@ -14,10 +14,11 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -#:import SimpleXHTMLWidget cagou.core.simple_xhtml.SimpleXHTMLWidget #:import _ sat.core.i18n._ #:import C cagou.core.constants.Const #:import escape kivy.utils.escape_markup +#:import SimpleXHTMLWidget cagou.core.simple_xhtml.SimpleXHTMLWidget +#:import DelayedBoxLayout cagou.core.common_widgets.DelayedBoxLayout : @@ -31,46 +32,38 @@ pos: self.pos size: self.size -: - cols: 1 - padding: dp(10) - spacing: dp(5) - size_hint: 1, None - height: self.minimum_height - canvas.before: - Color: - rgba: 1, 1, 1, 1 - Rectangle: - pos: self.pos - size: self.size : size_hint: 1, None - height: right_part.minimum_height - on_width: self.widthAdjust() avatar: avatar delivery: delivery mess_xhtml: mess_xhtml + right_part: right_part + header_box: header_box + height: self.minimum_height BoxLayout: orientation: 'vertical' width: avatar.width size_hint: None, 1 MessAvatar: id: avatar + source: (root.mess_data.avatar or '') if root.mess_data else '' Widget: # use to push the avatar on the top size_hint: 1, 1 BoxLayout: + size_hint: 1, None orientation: 'vertical' id: right_part + height: header_box.height + mess_xhtml.height BoxLayout: id: header_box size_hint: 1, None - height: time_label.height if root.mess_data.type != C.MESS_TYPE_INFO else 0 - opacity: 1 if root.mess_data.type != C.MESS_TYPE_INFO else 0 + height: time_label.height if root.mess_type != C.MESS_TYPE_INFO else 0 + opacity: 1 if root.mess_type != C.MESS_TYPE_INFO else 0 Label: id: time_label - color: (0, 0, 0, 1) if root.mess_data.own_mess else (0.55,0.55,0.55,1) + color: (0, 0, 0, 1) if root.own_mess else (0.55,0.55,0.55,1) font_size: root.font_size text_size: None, None size_hint: None, None @@ -78,7 +71,7 @@ padding: dp(5), 0 markup: True valign: 'middle' - text: u"[b]{}[/b], {}".format(escape(root.mess_data.nick), root.mess_data.time_text) + text: u"[b]{}[/b], {}".format(escape(root.nick), root.time_text) Label: id: delivery color: C.COLOR_BTN_LIGHT @@ -97,20 +90,26 @@ size_hint: 1, None height: self.minimum_height 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) + color: (0.74,0.74,0.24,1) if root.mess_type == "info" else (0, 0, 0, 1) padding: root.mess_padding - bold: True if root.mess_data.type == "info" else False + bold: True if root.mess_type == "info" else False + : + message_input: message_input messages_widget: messages_widget - message_input: message_input ScrollView: scroll_y: 0 do_scroll_x: False scroll_type: ['bars', 'content'] bar_width: dp(6) - MessagesWidget: + DelayedBoxLayout: id: messages_widget + size_hint_y: None + padding: [app.MARGIN_LEFT, 0, app.MARGIN_RIGHT, dp(10)] + spacing: dp(10) + height: self.minimum_height + orientation: 'vertical' MessageInputBox: size_hint: 1, None height: self.minimum_height diff -r 4374cb741eb5 -r 5868a5575e01 cagou/plugins/plugin_wid_chat.py --- a/cagou/plugins/plugin_wid_chat.py Fri Dec 06 13:23:03 2019 +0100 +++ b/cagou/plugins/plugin_wid_chat.py Fri Dec 06 13:25:31 2019 +0100 @@ -1,5 +1,4 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- # Cagou: desktop/mobile frontend for Salut à Toi XMPP client # Copyright (C) 2016-2019 Jérôme Poisson (goffi@goffi.org) @@ -21,27 +20,27 @@ from functools import partial import mimetypes import sys -from sat.core import log as logging -from sat.core.i18n import _ -from sat.core import exceptions -from cagou.core.constants import Const as C from kivy.uix.boxlayout import BoxLayout -from kivy.uix.gridlayout import GridLayout from kivy.uix.textinput import TextInput from kivy.metrics import sp, dp from kivy.clock import Clock from kivy import properties +from kivy.uix.dropdown import DropDown +from kivy.core.window import Window +from sat.core import log as logging +from sat.core.i18n import _ +from sat.core import exceptions from sat_frontends.quick_frontend import quick_widgets from sat_frontends.quick_frontend import quick_chat from sat_frontends.tools import jid +from cagou import G +from cagou.core.constants import Const as C from cagou.core import cagou_widget from cagou.core import xmlui from cagou.core.image import Image from cagou.core.common import SymbolButton, JidButton -from kivy.uix.dropdown import DropDown -from kivy.core.window import Window -from cagou import G from cagou.core import menu +# from random import randrange log = logging.getLogger(__name__) @@ -73,63 +72,64 @@ pass -class MessageWidget(BoxLayout, quick_chat.MessageWidget): +class MessageWidget(quick_chat.MessageWidget, BoxLayout): mess_data = properties.ObjectProperty() mess_xhtml = properties.ObjectProperty() mess_padding = (dp(5), dp(5)) avatar = properties.ObjectProperty() delivery = properties.ObjectProperty() font_size = properties.NumericProperty(sp(12)) + right_part = properties.ObjectProperty() + header_box = properties.ObjectProperty() - def __init__(self, **kwargs): - # self must be registered in widgets before kv is parsed - kwargs['mess_data'].widgets.add(self) - super(MessageWidget, self).__init__(**kwargs) - avatar_path = self.mess_data.avatar - if avatar_path is not None: - self.avatar.source = avatar_path + def on_mess_data(self, wid, mess_data): + mess_data.widgets.add(self) @property def chat(self): """return parent Chat instance""" return self.mess_data.parent + def _get_from_mess_data(self, name, default): + if self.mess_data is None: + return default + return getattr(self.mess_data, name) + def _get_message(self): """Return currently displayed message""" + if self.mess_data is None: + return "" return self.mess_data.main_message def _set_message(self, message): + if self.mess_data is None: + return False if message == self.mess_data.message.get(""): return False self.mess_data.message = {"": message} return True - message = properties.AliasProperty(_get_message, _set_message) - - @property - def message_xhtml(self): - """Return currently displayed message""" - return self.mess_data.main_message_xhtml + message = properties.AliasProperty( + partial(_get_from_mess_data, name="main_message", default=""), + _set_message, + bind=['mess_data'], + ) + message_xhtml = properties.AliasProperty( + partial(_get_from_mess_data, name="main_message_xhtml", default=""), + bind=['mess_data']) + mess_type = properties.AliasProperty( + partial(_get_from_mess_data, name="type", default=""), bind=['mess_data']) + own_mess = properties.AliasProperty( + partial(_get_from_mess_data, name="own_mess", default=False), bind=['mess_data']) + nick = properties.AliasProperty( + partial(_get_from_mess_data, name="nick", default=""), bind=['mess_data']) + time_text = properties.AliasProperty( + partial(_get_from_mess_data, name="time_text", default=""), bind=['mess_data']) @property def info_type(self): return self.mess_data.info_type - 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'] @@ -175,10 +175,6 @@ self.dispatch('on_text_validate') -class MessagesWidget(GridLayout): - pass - - class TransferButton(SymbolButton): chat = properties.ObjectProperty() @@ -426,8 +422,8 @@ self.extra_menu = ExtraMenu(chat=self) extra_btn = ExtraButton(chat=self) self.headerInputAddExtra(extra_btn) - self.header_input.hint_text = "{}".format(target) - self.postInit() + self.header_input.hint_text = target + Clock.schedule_once(lambda dt: self.postInit(), 0) def __str__(self): return "Chat({})".format(self.target)