Mercurial > libervia-desktop-kivy
comparison src/cagou/plugins/plugin_wid_chat.py @ 106:9909ed7a7a20
moved SimpleXHTMLWidget to a dedicated module
author | Goffi <goffi@goffi.org> |
---|---|
date | Sun, 15 Jan 2017 21:21:20 +0100 |
parents | ce6ef88f2cff |
children | 7631325e11f4 |
comparison
equal
deleted
inserted
replaced
105:ce6ef88f2cff | 106:9909ed7a7a20 |
---|---|
22 log = logging.getLogger(__name__) | 22 log = logging.getLogger(__name__) |
23 from sat.core.i18n import _ | 23 from sat.core.i18n import _ |
24 from cagou.core.constants import Const as C | 24 from cagou.core.constants import Const as C |
25 from kivy.uix.boxlayout import BoxLayout | 25 from kivy.uix.boxlayout import BoxLayout |
26 from kivy.uix.gridlayout import GridLayout | 26 from kivy.uix.gridlayout import GridLayout |
27 from kivy.uix.stacklayout import StackLayout | |
28 from kivy.uix.textinput import TextInput | 27 from kivy.uix.textinput import TextInput |
29 from kivy.uix.label import Label | |
30 from kivy.metrics import dp | 28 from kivy.metrics import dp |
31 from kivy.utils import escape_markup | |
32 from kivy import properties | 29 from kivy import properties |
33 from sat_frontends.quick_frontend import quick_widgets | 30 from sat_frontends.quick_frontend import quick_widgets |
34 from sat_frontends.quick_frontend import quick_chat | 31 from sat_frontends.quick_frontend import quick_chat |
35 from sat_frontends.tools import jid, css_color, strings as sat_strings | 32 from sat_frontends.tools import jid |
36 from cagou.core import cagou_widget | 33 from cagou.core import cagou_widget |
37 from cagou.core.image import Image, AsyncImage | 34 from cagou.core.image import Image |
38 from cagou import G | 35 from cagou import G |
39 from xml.etree import ElementTree as ET | |
40 import webbrowser | |
41 | 36 |
42 | 37 |
43 PLUGIN_INFO = { | 38 PLUGIN_INFO = { |
44 "name": _(u"chat"), | 39 "name": _(u"chat"), |
45 "main": "Chat", | 40 "main": "Chat", |
49 } | 44 } |
50 | 45 |
51 | 46 |
52 class MessAvatar(Image): | 47 class MessAvatar(Image): |
53 pass | 48 pass |
54 | |
55 | |
56 class Escape(unicode): | |
57 """Class used to mark that a message need to be escaped""" | |
58 | |
59 def __init__(self, text): | |
60 super(Escape, self).__init__(text) | |
61 | |
62 | |
63 class SimpleXHTMLWidgetEscapedText(Label): | |
64 | |
65 def _addUrlMarkup(self, text): | |
66 text_elts = [] | |
67 idx = 0 | |
68 links = 0 | |
69 while True: | |
70 m = sat_strings.RE_URL.search(text[idx:]) | |
71 if m is not None: | |
72 text_elts.append(escape_markup(m.string[0:m.start()])) | |
73 link_key = u'link_' + unicode(links) | |
74 url = m.group() | |
75 text_elts.append(u'[color=5500ff][ref={link}]{url}[/ref][/color]'.format( | |
76 link = link_key, | |
77 url = url | |
78 )) | |
79 if not links: | |
80 self.ref_urls = {link_key: url} | |
81 else: | |
82 self.ref_urls[link_key] = url | |
83 links += 1 | |
84 idx += m.end() | |
85 else: | |
86 if links: | |
87 text_elts.append(escape_markup(text[idx:])) | |
88 self.markup = True | |
89 self.text = u''.join(text_elts) | |
90 break | |
91 | |
92 def on_text(self, instance, text): | |
93 # do NOT call the method if self.markup is set | |
94 # this would result in infinite loop (because self.text | |
95 # is changed if an URL is found, and in this case markup too) | |
96 if text and not self.markup: | |
97 self._addUrlMarkup(text) | |
98 | |
99 def on_ref_press(self, ref): | |
100 url = self.ref_urls[ref] | |
101 webbrowser.open(url) | |
102 | |
103 | |
104 class SimpleXHTMLWidgetText(Label): | |
105 pass | |
106 | |
107 | |
108 class SimpleXHTMLWidgetImage(AsyncImage): | |
109 # following properties are desired height/width | |
110 # i.e. the ones specified in height/width attributes of <img> | |
111 # (or wanted for whatever reason) | |
112 # set to 0 to ignore them | |
113 target_height = properties.NumericProperty() | |
114 target_width = properties.NumericProperty() | |
115 | |
116 def _get_parent_container(self): | |
117 """get parent SimpleXHTMLWidget instance | |
118 | |
119 @param warning(bool): if True display a log.error if nothing found | |
120 @return (SimpleXHTMLWidget, None): found SimpleXHTMLWidget instance | |
121 """ | |
122 parent = self.parent | |
123 while parent and not isinstance(parent, SimpleXHTMLWidget): | |
124 parent = parent.parent | |
125 if parent is None: | |
126 log.error(u"no SimpleXHTMLWidget parent found") | |
127 return parent | |
128 | |
129 def _on_source_load(self, value): | |
130 # this method is called when image is loaded | |
131 super(SimpleXHTMLWidgetImage, self)._on_source_load(value) | |
132 if self.parent is not None: | |
133 container = self._get_parent_container() | |
134 # image is loaded, we need to recalculate size | |
135 self.on_container_width(container, container.width) | |
136 | |
137 def on_container_width(self, container, container_width): | |
138 """adapt size according to container width | |
139 | |
140 called when parent container (SimpleXHTMLWidget) width change | |
141 """ | |
142 target_size = (self.target_width or self.texture.width, self.target_height or self.texture.height) | |
143 padding = container.padding | |
144 padding_h = (padding[0] + padding[2]) if len(padding) == 4 else padding[0] | |
145 width = container_width - padding_h | |
146 if target_size[0] < width: | |
147 self.size = target_size | |
148 else: | |
149 height = width / self.image_ratio | |
150 self.size = (width, height) | |
151 | |
152 def on_parent(self, instance, parent): | |
153 if parent is not None: | |
154 container = self._get_parent_container() | |
155 container.bind(width=self.on_container_width) | |
156 | |
157 | |
158 class SimpleXHTMLWidget(StackLayout): | |
159 """widget handling simple XHTML parsing""" | |
160 xhtml = properties.StringProperty() | |
161 color = properties.ListProperty([1, 1, 1, 1]) | |
162 # XXX: bold is only used for escaped text | |
163 bold = properties.BooleanProperty(False) | |
164 content_width = properties.NumericProperty(0) | |
165 | |
166 # text/XHTML input | |
167 | |
168 def on_xhtml(self, instance, xhtml): | |
169 """parse xhtml and set content accordingly | |
170 | |
171 if xhtml is an instance of Escape, a Label with not markup | |
172 will be used | |
173 """ | |
174 self.clear_widgets() | |
175 if isinstance(xhtml, Escape): | |
176 label = SimpleXHTMLWidgetEscapedText(text=xhtml, color=self.color) | |
177 self.bind(color=label.setter('color')) | |
178 self.bind(bold=label.setter('bold')) | |
179 self.add_widget(label) | |
180 else: | |
181 xhtml = ET.fromstring(xhtml.encode('utf-8')) | |
182 self.current_wid = None | |
183 self.styles = [] | |
184 self._callParseMethod(xhtml) | |
185 | |
186 def escape(self, text): | |
187 """mark that a text need to be escaped (i.e. no markup)""" | |
188 return Escape(text) | |
189 | |
190 # sizing | |
191 | |
192 def on_width(self, instance, width): | |
193 if len(self.children) == 1: | |
194 wid = self.children[0] | |
195 if isinstance(wid, Label): | |
196 # we have simple text | |
197 try: | |
198 full_width = wid._full_width | |
199 except AttributeError: | |
200 # on first time, we need the required size | |
201 # for the full text, without width limit | |
202 wid.size_hint = (None, None) | |
203 wid.texture_update() | |
204 full_width = wid._full_width = wid.texture_size[0] | |
205 | |
206 if full_width > width: | |
207 wid.text_size = width, None | |
208 wid.width = width | |
209 else: | |
210 wid.text_size = None, None | |
211 wid.texture_update() | |
212 wid.width = wid.texture_size[0] | |
213 self.content_width = wid.width + self.padding[0] + self.padding[2] | |
214 else: | |
215 wid.size_hint = (1, None) | |
216 wid.height = 100 | |
217 self.content_width = self.width | |
218 else: | |
219 self._do_complexe_sizing(width) | |
220 | |
221 def _do_complexe_sizing(self, width): | |
222 try: | |
223 self.splitted | |
224 except AttributeError: | |
225 # XXX: to make things easier, we split labels in words | |
226 log.debug(u"split start") | |
227 children = self.children[::-1] | |
228 self.clear_widgets() | |
229 for child in children: | |
230 if isinstance(child, Label): | |
231 log.debug(u"label before split: {}".format(child.text)) | |
232 styles = [] | |
233 tag = False | |
234 new_text = [] | |
235 current_tag = [] | |
236 current_value = [] | |
237 current_wid = self._createText() | |
238 value = False | |
239 close = False | |
240 # we will parse the text and create a new widget | |
241 # on each new word (actually each space) | |
242 # FIXME: handle '\n' and other white chars | |
243 for c in child.text: | |
244 if tag: | |
245 # we are parsing a markup tag | |
246 if c == u']': | |
247 current_tag_s = u''.join(current_tag) | |
248 current_style = (current_tag_s, u''.join(current_value)) | |
249 if close: | |
250 for idx, s in enumerate(reversed(styles)): | |
251 if s[0] == current_tag_s: | |
252 del styles[len(styles) - idx - 1] | |
253 break | |
254 else: | |
255 styles.append(current_style) | |
256 current_tag = [] | |
257 current_value = [] | |
258 tag = False | |
259 value = False | |
260 close = False | |
261 elif c == u'/': | |
262 close = True | |
263 elif c == u'=': | |
264 value = True | |
265 elif value: | |
266 current_value.append(c) | |
267 else: | |
268 current_tag.append(c) | |
269 new_text.append(c) | |
270 else: | |
271 # we are parsing regular text | |
272 if c == u'[': | |
273 new_text.append(c) | |
274 tag = True | |
275 elif c == u' ': | |
276 # new word, we do a new widget | |
277 new_text.append(u' ') | |
278 for t, v in reversed(styles): | |
279 new_text.append(u'[/{}]'.format(t)) | |
280 current_wid.text = u''.join(new_text) | |
281 new_text = [] | |
282 self.add_widget(current_wid) | |
283 log.debug(u"new widget: {}".format(current_wid.text)) | |
284 current_wid = self._createText() | |
285 for t, v in styles: | |
286 new_text.append(u'[{tag}{value}]'.format( | |
287 tag = t, | |
288 value = u'={}'.format(v) if v else u'')) | |
289 else: | |
290 new_text.append(c) | |
291 if current_wid.text: | |
292 # we may have a remaining widget after the parsing | |
293 close_styles = [] | |
294 for t, v in reversed(styles): | |
295 close_styles.append(u'[/{}]'.format(t)) | |
296 current_wid.text = u''.join(close_styles) | |
297 self.add_widget(current_wid) | |
298 log.debug(u"new widget: {}".format(current_wid.text)) | |
299 else: | |
300 # non Label widgets, we just add them | |
301 self.add_widget(child) | |
302 self.splitted = True | |
303 log.debug(u"split OK") | |
304 | |
305 # we now set the content width | |
306 # FIXME: for now we just use the full width | |
307 self.content_width = width | |
308 | |
309 # XHTML parsing methods | |
310 | |
311 def _callParseMethod(self, e): | |
312 """call the suitable method to parse the element | |
313 | |
314 self.xhtml_[tag] will be called if it exists, else | |
315 self.xhtml_generic will be used | |
316 @param e(ET.Element): element to parse | |
317 """ | |
318 try: | |
319 method = getattr(self, "xhtml_{}".format(e.tag)) | |
320 except AttributeError: | |
321 log.warning(u"Unhandled XHTML tag: {}".format(e.tag)) | |
322 method = self.xhtml_generic | |
323 method(e) | |
324 | |
325 def _addStyle(self, tag, value=None, append_to_list=True): | |
326 """add a markup style to label | |
327 | |
328 @param tag(unicode): markup tag | |
329 @param value(unicode): markup value if suitable | |
330 @param append_to_list(bool): if True style we be added to self.styles | |
331 self.styles is needed to keep track of styles to remove | |
332 should most probably be set to True | |
333 """ | |
334 label = self._getLabel() | |
335 label.text += u'[{tag}{value}]'.format( | |
336 tag = tag, | |
337 value = u'={}'.format(value) if value else '' | |
338 ) | |
339 if append_to_list: | |
340 self.styles.append((tag, value)) | |
341 | |
342 def _removeStyle(self, tag, remove_from_list=True): | |
343 """remove a markup style from the label | |
344 | |
345 @param tag(unicode): markup tag to remove | |
346 @param remove_from_list(bool): if True, remove from self.styles too | |
347 should most probably be set to True | |
348 """ | |
349 label = self._getLabel() | |
350 label.text += u'[/{tag}]'.format( | |
351 tag = tag | |
352 ) | |
353 if remove_from_list: | |
354 for rev_idx, style in enumerate(reversed(self.styles)): | |
355 if style[0] == tag: | |
356 tag_idx = len(self.styles) - 1 - rev_idx | |
357 del self.styles[tag_idx] | |
358 break | |
359 | |
360 def _getLabel(self): | |
361 """get current Label if it exists, or create a new one""" | |
362 if not isinstance(self.current_wid, Label): | |
363 self._addLabel() | |
364 return self.current_wid | |
365 | |
366 def _addLabel(self): | |
367 """add a new Label | |
368 | |
369 current styles will be closed and reopened if needed | |
370 """ | |
371 self._closeLabel() | |
372 self.current_wid = self._createText() | |
373 for tag, value in self.styles: | |
374 self._addStyle(tag, value, append_to_list=False) | |
375 self.add_widget(self.current_wid) | |
376 | |
377 def _createText(self): | |
378 label = SimpleXHTMLWidgetText(color=self.color, markup=True) | |
379 self.bind(color=label.setter('color')) | |
380 label.bind(texture_size=label.setter('size')) | |
381 return label | |
382 | |
383 def _closeLabel(self): | |
384 """close current style tags in current label | |
385 | |
386 needed when you change label to keep style between | |
387 different widgets | |
388 """ | |
389 if isinstance(self.current_wid, Label): | |
390 for tag, value in reversed(self.styles): | |
391 self._removeStyle(tag, remove_from_list=False) | |
392 | |
393 def _parseCSS(self, e): | |
394 """parse CSS found in "style" attribute of element | |
395 | |
396 self._css_styles will be created and contained markup styles added by this method | |
397 @param e(ET.Element): element which may have a "style" attribute | |
398 """ | |
399 styles_limit = len(self.styles) | |
400 styles = e.attrib['style'].split(u';') | |
401 for style in styles: | |
402 try: | |
403 prop, value = style.split(u':') | |
404 except ValueError: | |
405 log.warning(u"can't parse style: {}".format(style)) | |
406 continue | |
407 prop = prop.strip().replace(u'-', '_') | |
408 value = value.strip() | |
409 try: | |
410 method = getattr(self, "css_{}".format(prop)) | |
411 except AttributeError: | |
412 log.warning(u"Unhandled CSS: {}".format(prop)) | |
413 else: | |
414 method(e, value) | |
415 self._css_styles = self.styles[styles_limit:] | |
416 | |
417 def _closeCSS(self): | |
418 """removed CSS styles | |
419 | |
420 styles in self._css_styles will be removed | |
421 and the attribute will be deleted | |
422 """ | |
423 for tag, dummy in reversed(self._css_styles): | |
424 self._removeStyle(tag) | |
425 del self._css_styles | |
426 | |
427 def xhtml_generic(self, elem, style=True, markup=None): | |
428 """generic method for adding HTML elements | |
429 | |
430 this method handle content, style and children parsing | |
431 @param elem(ET.Element): element to add | |
432 @param style(bool): if True handle style attribute (CSS) | |
433 @param markup(tuple[unicode, (unicode, None)], None): kivy markup to use | |
434 """ | |
435 # we first add markup and CSS style | |
436 if markup is not None: | |
437 if isinstance(markup, basestring): | |
438 tag, value = markup, None | |
439 else: | |
440 tag, value = markup | |
441 self._addStyle(tag, value) | |
442 style_ = 'style' in elem.attrib and style | |
443 if style_: | |
444 self._parseCSS(elem) | |
445 | |
446 # then content | |
447 if elem.text: | |
448 self._getLabel().text += escape_markup(elem.text) | |
449 | |
450 # we parse the children | |
451 for child in elem: | |
452 self._callParseMethod(child) | |
453 | |
454 # closing CSS style and markup | |
455 if style_: | |
456 self._closeCSS() | |
457 if markup is not None: | |
458 self._removeStyle(tag) | |
459 | |
460 # and the tail, which is regular text | |
461 if elem.tail: | |
462 self._getLabel().text += escape_markup(elem.tail) | |
463 | |
464 # method handling XHTML elements | |
465 | |
466 def xhtml_br(self, elem): | |
467 label = self._getLabel() | |
468 label.text+='\n' | |
469 self.xhtml_generic(style=False) | |
470 | |
471 def xhtml_em(self, elem): | |
472 self.xhtml_generic(elem, markup='i') | |
473 | |
474 def xhtml_img(self, elem): | |
475 try: | |
476 src = elem.attrib['src'] | |
477 except KeyError: | |
478 log.warning(u"<img> element without src: {}".format(ET.tostring(elem))) | |
479 return | |
480 try: | |
481 target_height = int(elem.get(u'height', 0)) | |
482 except ValueError: | |
483 log.warning(u"Can't parse image height: {}".format(elem.get(u'height'))) | |
484 target_height = 0 | |
485 try: | |
486 target_width = int(elem.get(u'width', 0)) | |
487 except ValueError: | |
488 log.warning(u"Can't parse image width: {}".format(elem.get(u'width'))) | |
489 target_width = 0 | |
490 | |
491 img = SimpleXHTMLWidgetImage(source=src, target_height=target_height, target_width=target_width) | |
492 self.current_wid = img | |
493 self.add_widget(img) | |
494 | |
495 def xhtml_p(self, elem): | |
496 if isinstance(self.current_wid, Label): | |
497 self.current_wid.text+="\n\n" | |
498 self.xhtml_generic(elem) | |
499 | |
500 def xhtml_span(self, elem): | |
501 self.xhtml_generic(elem) | |
502 | |
503 def xhtml_strong(self, elem): | |
504 self.xhtml_generic(elem, markup='b') | |
505 | |
506 # methods handling CSS properties | |
507 | |
508 def css_color(self, elem, value): | |
509 self._addStyle(u"color", css_color.parse(value)) | |
510 | |
511 def css_text_decoration(self, elem, value): | |
512 if value == u'underline': | |
513 log.warning(u"{} not handled yet, it needs Kivy 1.9.2 to be released".format(value)) | |
514 # FIXME: activate when 1.9.2 is out | |
515 # self._addStyle('u') | |
516 elif value == u'line-through': | |
517 log.warning(u"{} not handled yet, it needs Kivy 1.9.2 to be released".format(value)) | |
518 # FIXME: activate when 1.9.2 is out | |
519 # self._addStyle('s') | |
520 else: | |
521 log.warning(u"unhandled text decoration: {}".format(value)) | |
522 | 49 |
523 | 50 |
524 class MessageWidget(GridLayout): | 51 class MessageWidget(GridLayout): |
525 mess_data = properties.ObjectProperty() | 52 mess_data = properties.ObjectProperty() |
526 mess_xhtml = properties.ObjectProperty() | 53 mess_xhtml = properties.ObjectProperty() |