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()