Mercurial > libervia-desktop-kivy
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 |