comparison cagou/core/simple_xhtml.py @ 325:5868a5575e01

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.
author Goffi <goffi@goffi.org>
date Fri, 06 Dec 2019 13:25:31 +0100
parents 772c170b47a9
children 597cc207c8e7
comparison
equal deleted inserted replaced
324:4374cb741eb5 325:5868a5575e01
16 16
17 # You should have received a copy of the GNU Affero General Public License 17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. 18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 19
20 20
21 from sat.core import log as logging 21 import webbrowser
22 log = logging.getLogger(__name__) 22 from xml.etree import ElementTree as ET
23 from kivy.uix.stacklayout import StackLayout 23 from kivy.uix.stacklayout import StackLayout
24 from kivy.uix.label import Label 24 from kivy.uix.label import Label
25 from kivy.utils import escape_markup 25 from kivy.utils import escape_markup
26 from kivy.metrics import sp 26 from kivy.metrics import sp, dp
27 from kivy import properties 27 from kivy import properties
28 from xml.etree import ElementTree as ET 28 from sat.core import log as logging
29 from sat_frontends.tools import css_color, strings as sat_strings 29 from sat_frontends.tools import css_color, strings as sat_strings
30 from cagou.core.image import AsyncImage 30 from cagou.core.image import AsyncImage
31 import webbrowser 31 from cagou.core.constants import Const as C
32
33
34 log = logging.getLogger(__name__)
32 35
33 36
34 class Escape(str): 37 class Escape(str):
35 """Class used to mark that a message need to be escaped""" 38 """Class used to mark that a message need to be escaped"""
36 39
49 m = sat_strings.RE_URL.search(text[idx:]) 52 m = sat_strings.RE_URL.search(text[idx:])
50 if m is not None: 53 if m is not None:
51 text_elts.append(escape_markup(m.string[0:m.start()])) 54 text_elts.append(escape_markup(m.string[0:m.start()]))
52 link_key = 'link_' + str(links) 55 link_key = 'link_' + str(links)
53 url = m.group() 56 url = m.group()
54 text_elts.append('[color=5500ff][ref={link}]{url}[/ref][/color]'.format( 57 escaped_url = escape_markup(url)
55 link = link_key, 58 text_elts.append(
56 url = url 59 f'[color=5500ff][ref={link_key}]{escaped_url}[/ref][/color]')
57 ))
58 if not links: 60 if not links:
59 self.ref_urls = {link_key: url} 61 self.ref_urls = {link_key: url}
60 else: 62 else:
61 self.ref_urls[link_key] = url 63 self.ref_urls[link_key] = url
62 links += 1 64 links += 1
81 83
82 84
83 class SimpleXHTMLWidgetText(Label): 85 class SimpleXHTMLWidgetText(Label):
84 86
85 def on_parent(self, instance, parent): 87 def on_parent(self, instance, parent):
86 self.font_size = parent.font_size 88 if parent is not None:
89 self.font_size = parent.font_size
87 90
88 91
89 class SimpleXHTMLWidgetImage(AsyncImage): 92 class SimpleXHTMLWidgetImage(AsyncImage):
90 # following properties are desired height/width 93 # following properties are desired height/width
91 # i.e. the ones specified in height/width attributes of <img> 94 # i.e. the ones specified in height/width attributes of <img>
92 # (or wanted for whatever reason) 95 # (or wanted for whatever reason)
93 # set to 0 to ignore them 96 # set to None to ignore them
94 target_height = properties.NumericProperty() 97 target_height = properties.NumericProperty(allownone=True)
95 target_width = properties.NumericProperty() 98 target_width = properties.NumericProperty(allownone=True)
96 99
97 def _get_parent_container(self): 100 def __init__(self, **kwargs):
98 """get parent SimpleXHTMLWidget instance 101 # best calculated size
99 102 self._best_width = self._best_height = 100
100 @param warning(bool): if True display a log.error if nothing found 103 super().__init__(**kwargs)
101 @return (SimpleXHTMLWidget, None): found SimpleXHTMLWidget instance 104
102 """ 105 def on_texture(self, instance, texture):
103 parent = self.parent 106 """Adapt the size according to max size and target_*"""
104 while parent and not isinstance(parent, SimpleXHTMLWidget): 107 if texture is None:
105 parent = parent.parent 108 return
106 if parent is None: 109 max_width, max_height = dp(C.IMG_MAX_WIDTH), dp(C.IMG_MAX_HEIGHT)
107 log.error("no SimpleXHTMLWidget parent found") 110 width, height = texture.size
108 return parent 111 if self.target_width:
109 112 width = min(width, self.target_width)
110 def _on_source_load(self, value): 113 if width > max_width:
111 # this method is called when image is loaded 114 width = C.IMG_MAX_WIDTH
112 super(SimpleXHTMLWidgetImage, self)._on_source_load(value) 115
113 if self.parent is not None: 116 height = width / self.image_ratio
114 container = self._get_parent_container() 117
115 # image is loaded, we need to recalculate size 118 if self.target_height:
116 self.on_container_width(container, container.width) 119 height = min(height, self.target_height)
117 120
118 def on_container_width(self, container, container_width): 121 if height > max_height:
119 """adapt size according to container width 122 height = max_height
120 123 width = height * self.image_ratio
121 called when parent container (SimpleXHTMLWidget) width change 124
122 """ 125 self.width, self.height = self._best_width, self._best_height = width, height
123 target_size = (self.target_width or self.texture.width, self.target_height or self.texture.height)
124 padding = container.padding
125 padding_h = (padding[0] + padding[2]) if len(padding) == 4 else padding[0]
126 width = container_width - padding_h
127 if target_size[0] < width:
128 self.size = target_size
129 else:
130 height = width / self.image_ratio
131 self.size = (width, height)
132 126
133 def on_parent(self, instance, parent): 127 def on_parent(self, instance, parent):
134 if parent is not None: 128 if parent is not None:
135 container = self._get_parent_container() 129 parent.bind(width=self.on_parent_width)
136 container.bind(width=self.on_container_width) 130
131 def on_parent_width(self, instance, width):
132 if self._best_width > width:
133 self.width = width
134 self.height = width / self.image_ratio
135 else:
136 self.width, self.height = self._best_width, self._best_height
137 137
138 138
139 class SimpleXHTMLWidget(StackLayout): 139 class SimpleXHTMLWidget(StackLayout):
140 """widget handling simple XHTML parsing""" 140 """widget handling simple XHTML parsing"""
141 xhtml = properties.StringProperty() 141 xhtml = properties.StringProperty()
142 color = properties.ListProperty([1, 1, 1, 1]) 142 color = properties.ListProperty([1, 1, 1, 1])
143 # XXX: bold is only used for escaped text 143 # XXX: bold is only used for escaped text
144 bold = properties.BooleanProperty(False) 144 bold = properties.BooleanProperty(False)
145 content_width = properties.NumericProperty(0)
146 font_size = properties.NumericProperty(sp(14)) 145 font_size = properties.NumericProperty(sp(14))
147 146
148 # text/XHTML input 147 # text/XHTML input
149 148
150 def on_xhtml(self, instance, xhtml): 149 def on_xhtml(self, instance, xhtml):
154 """ 153 """
155 self.clear_widgets() 154 self.clear_widgets()
156 if isinstance(xhtml, Escape): 155 if isinstance(xhtml, Escape):
157 label = SimpleXHTMLWidgetEscapedText( 156 label = SimpleXHTMLWidgetEscapedText(
158 text=xhtml, color=self.color, bold=self.bold) 157 text=xhtml, color=self.color, bold=self.bold)
158 self.bind(font_size=label.setter('font_size'))
159 self.bind(color=label.setter('color')) 159 self.bind(color=label.setter('color'))
160 self.bind(bold=label.setter('bold')) 160 self.bind(bold=label.setter('bold'))
161 self.add_widget(label) 161 self.add_widget(label)
162 else: 162 else:
163 xhtml = ET.fromstring(xhtml.encode('utf-8')) 163 xhtml = ET.fromstring(xhtml.encode())
164 self.current_wid = None 164 self.current_wid = None
165 self.styles = [] 165 self.styles = []
166 self._callParseMethod(xhtml) 166 self._callParseMethod(xhtml)
167 if len(self.children) > 1:
168 self._do_split_labels()
167 169
168 def escape(self, text): 170 def escape(self, text):
169 """mark that a text need to be escaped (i.e. no markup)""" 171 """mark that a text need to be escaped (i.e. no markup)"""
170 return Escape(text) 172 return Escape(text)
171 173
172 # sizing 174 def _do_split_labels(self):
173 175 """Split labels so their content can flow with images"""
174 def on_width(self, instance, width): 176 # XXX: to make things easier, we split labels in words
175 if len(self.children) == 1: 177 log.debug("labels splitting start")
176 wid = self.children[0] 178 children = self.children[::-1]
177 if isinstance(wid, Label): 179 self.clear_widgets()
178 # we have simple text 180 for child in children:
179 try: 181 if isinstance(child, Label):
180 full_width = wid._full_width 182 log.debug("label before split: {}".format(child.text))
181 except AttributeError: 183 styles = []
182 # on first time, we need the required size 184 tag = False
183 # for the full text, without width limit 185 new_text = []
184 wid.size_hint = (None, None) 186 current_tag = []
185 wid.texture_update() 187 current_value = []
186 full_width = wid._full_width = wid.texture_size[0] 188 current_wid = self._createText()
187 189 value = False
188 if full_width > width: 190 close = False
189 wid.text_size = width, None 191 # we will parse the text and create a new widget
190 wid.width = width 192 # on each new word (actually each space)
191 else: 193 # FIXME: handle '\n' and other white chars
192 wid.text_size = None, None 194 for c in child.text:
193 wid.texture_update() 195 if tag:
194 wid.width = wid.texture_size[0] 196 # we are parsing a markup tag
195 self.content_width = wid.width + self.padding[0] + self.padding[2] 197 if c == ']':
198 current_tag_s = ''.join(current_tag)
199 current_style = (current_tag_s, ''.join(current_value))
200 if close:
201 for idx, s in enumerate(reversed(styles)):
202 if s[0] == current_tag_s:
203 del styles[len(styles) - idx - 1]
204 break
205 else:
206 styles.append(current_style)
207 current_tag = []
208 current_value = []
209 tag = False
210 value = False
211 close = False
212 elif c == '/':
213 close = True
214 elif c == '=':
215 value = True
216 elif value:
217 current_value.append(c)
218 else:
219 current_tag.append(c)
220 new_text.append(c)
221 else:
222 # we are parsing regular text
223 if c == '[':
224 new_text.append(c)
225 tag = True
226 elif c == ' ':
227 # new word, we do a new widget
228 new_text.append(' ')
229 for t, v in reversed(styles):
230 new_text.append('[/{}]'.format(t))
231 current_wid.text = ''.join(new_text)
232 new_text = []
233 self.add_widget(current_wid)
234 log.debug("new widget: {}".format(current_wid.text))
235 current_wid = self._createText()
236 for t, v in styles:
237 new_text.append('[{tag}{value}]'.format(
238 tag = t,
239 value = '={}'.format(v) if v else ''))
240 else:
241 new_text.append(c)
242 if current_wid.text:
243 # we may have a remaining widget after the parsing
244 close_styles = []
245 for t, v in reversed(styles):
246 close_styles.append('[/{}]'.format(t))
247 current_wid.text = ''.join(close_styles)
248 self.add_widget(current_wid)
249 log.debug("new widget: {}".format(current_wid.text))
196 else: 250 else:
197 wid.size_hint = (1, None) 251 # non Label widgets, we just add them
198 wid.height = 100 252 self.add_widget(child)
199 self.content_width = self.width 253 self.splitted = True
200 else: 254 log.debug("split OK")
201 self._do_complexe_sizing(width)
202
203 def _do_complexe_sizing(self, width):
204 try:
205 self.splitted
206 except AttributeError:
207 # XXX: to make things easier, we split labels in words
208 log.debug("split start")
209 children = self.children[::-1]
210 self.clear_widgets()
211 for child in children:
212 if isinstance(child, Label):
213 log.debug("label before split: {}".format(child.text))
214 styles = []
215 tag = False
216 new_text = []
217 current_tag = []
218 current_value = []
219 current_wid = self._createText()
220 value = False
221 close = False
222 # we will parse the text and create a new widget
223 # on each new word (actually each space)
224 # FIXME: handle '\n' and other white chars
225 for c in child.text:
226 if tag:
227 # we are parsing a markup tag
228 if c == ']':
229 current_tag_s = ''.join(current_tag)
230 current_style = (current_tag_s, ''.join(current_value))
231 if close:
232 for idx, s in enumerate(reversed(styles)):
233 if s[0] == current_tag_s:
234 del styles[len(styles) - idx - 1]
235 break
236 else:
237 styles.append(current_style)
238 current_tag = []
239 current_value = []
240 tag = False
241 value = False
242 close = False
243 elif c == '/':
244 close = True
245 elif c == '=':
246 value = True
247 elif value:
248 current_value.append(c)
249 else:
250 current_tag.append(c)
251 new_text.append(c)
252 else:
253 # we are parsing regular text
254 if c == '[':
255 new_text.append(c)
256 tag = True
257 elif c == ' ':
258 # new word, we do a new widget
259 new_text.append(' ')
260 for t, v in reversed(styles):
261 new_text.append('[/{}]'.format(t))
262 current_wid.text = ''.join(new_text)
263 new_text = []
264 self.add_widget(current_wid)
265 log.debug("new widget: {}".format(current_wid.text))
266 current_wid = self._createText()
267 for t, v in styles:
268 new_text.append('[{tag}{value}]'.format(
269 tag = t,
270 value = '={}'.format(v) if v else ''))
271 else:
272 new_text.append(c)
273 if current_wid.text:
274 # we may have a remaining widget after the parsing
275 close_styles = []
276 for t, v in reversed(styles):
277 close_styles.append('[/{}]'.format(t))
278 current_wid.text = ''.join(close_styles)
279 self.add_widget(current_wid)
280 log.debug("new widget: {}".format(current_wid.text))
281 else:
282 # non Label widgets, we just add them
283 self.add_widget(child)
284 self.splitted = True
285 log.debug("split OK")
286
287 # we now set the content width
288 # FIXME: for now we just use the full width
289 self.content_width = width
290 255
291 # XHTML parsing methods 256 # XHTML parsing methods
292 257
293 def _callParseMethod(self, e): 258 def _callParseMethod(self, e):
294 """call the suitable method to parse the element 259 """Call the suitable method to parse the element
295 260
296 self.xhtml_[tag] will be called if it exists, else 261 self.xhtml_[tag] will be called if it exists, else
297 self.xhtml_generic will be used 262 self.xhtml_generic will be used
298 @param e(ET.Element): element to parse 263 @param e(ET.Element): element to parse
299 """ 264 """
300 try: 265 try:
301 method = getattr(self, "xhtml_{}".format(e.tag)) 266 method = getattr(self, f"xhtml_{e.tag}")
302 except AttributeError: 267 except AttributeError:
303 log.warning("Unhandled XHTML tag: {}".format(e.tag)) 268 log.warning(f"Unhandled XHTML tag: {e.tag}")
304 method = self.xhtml_generic 269 method = self.xhtml_generic
305 method(e) 270 method(e)
306 271
307 def _addStyle(self, tag, value=None, append_to_list=True): 272 def _addStyle(self, tag, value=None, append_to_list=True):
308 """add a markup style to label 273 """add a markup style to label
382 styles = e.attrib['style'].split(';') 347 styles = e.attrib['style'].split(';')
383 for style in styles: 348 for style in styles:
384 try: 349 try:
385 prop, value = style.split(':') 350 prop, value = style.split(':')
386 except ValueError: 351 except ValueError:
387 log.warning("can't parse style: {}".format(style)) 352 log.warning(f"can't parse style: {style}")
388 continue 353 continue
389 prop = prop.strip().replace('-', '_') 354 prop = prop.strip().replace('-', '_')
390 value = value.strip() 355 value = value.strip()
391 try: 356 try:
392 method = getattr(self, "css_{}".format(prop)) 357 method = getattr(self, f"css_{prop}")
393 except AttributeError: 358 except AttributeError:
394 log.warning("Unhandled CSS: {}".format(prop)) 359 log.warning(f"Unhandled CSS: {prop}")
395 else: 360 else:
396 method(e, value) 361 method(e, value)
397 self._css_styles = self.styles[styles_limit:] 362 self._css_styles = self.styles[styles_limit:]
398 363
399 def _closeCSS(self): 364 def _closeCSS(self):
405 for tag, __ in reversed(self._css_styles): 370 for tag, __ in reversed(self._css_styles):
406 self._removeStyle(tag) 371 self._removeStyle(tag)
407 del self._css_styles 372 del self._css_styles
408 373
409 def xhtml_generic(self, elem, style=True, markup=None): 374 def xhtml_generic(self, elem, style=True, markup=None):
410 """generic method for adding HTML elements 375 """Generic method for adding HTML elements
411 376
412 this method handle content, style and children parsing 377 this method handle content, style and children parsing
413 @param elem(ET.Element): element to add 378 @param elem(ET.Element): element to add
414 @param style(bool): if True handle style attribute (CSS) 379 @param style(bool): if True handle style attribute (CSS)
415 @param markup(tuple[unicode, (unicode, None)], None): kivy markup to use 380 @param markup(tuple[unicode, (unicode, None)], None): kivy markup to use
460 log.warning("<img> element without src: {}".format(ET.tostring(elem))) 425 log.warning("<img> element without src: {}".format(ET.tostring(elem)))
461 return 426 return
462 try: 427 try:
463 target_height = int(elem.get('height', 0)) 428 target_height = int(elem.get('height', 0))
464 except ValueError: 429 except ValueError:
465 log.warning("Can't parse image height: {}".format(elem.get('height'))) 430 log.warning(f"Can't parse image height: {elem.get('height')}")
466 target_height = 0 431 target_height = None
467 try: 432 try:
468 target_width = int(elem.get('width', 0)) 433 target_width = int(elem.get('width', 0))
469 except ValueError: 434 except ValueError:
470 log.warning("Can't parse image width: {}".format(elem.get('width'))) 435 log.warning(f"Can't parse image width: {elem.get('width')}")
471 target_width = 0 436 target_width = None
472 437
473 img = SimpleXHTMLWidgetImage(source=src, target_height=target_height, target_width=target_width) 438 img = SimpleXHTMLWidgetImage(source=src, target_height=target_height, target_width=target_width)
474 self.current_wid = img 439 self.current_wid = img
475 self.add_widget(img) 440 self.add_widget(img)
476 441