Mercurial > libervia-backend
comparison libervia/frontends/tools/xmlui.py @ 4074:26b7ed2817da
refactoring: rename `sat_frontends` to `libervia.frontends`
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 14:12:38 +0200 |
parents | sat_frontends/tools/xmlui.py@4b842c1fb686 |
children | 0d7bb4df2343 |
comparison
equal
deleted
inserted
replaced
4073:7c5654c54fed | 4074:26b7ed2817da |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 | |
4 # SàT frontend tools | |
5 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) | |
6 | |
7 # This program is free software: you can redistribute it and/or modify | |
8 # it under the terms of the GNU Affero General Public License as published by | |
9 # the Free Software Foundation, either version 3 of the License, or | |
10 # (at your option) any later version. | |
11 | |
12 # This program is distributed in the hope that it will be useful, | |
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
15 # GNU Affero General Public License for more details. | |
16 | |
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/>. | |
19 | |
20 from libervia.backend.core.i18n import _ | |
21 from libervia.backend.core.log import getLogger | |
22 | |
23 log = getLogger(__name__) | |
24 from libervia.frontends.quick_frontend.constants import Const as C | |
25 from libervia.backend.core import exceptions | |
26 | |
27 | |
28 _class_map = {} | |
29 CLASS_PANEL = "panel" | |
30 CLASS_DIALOG = "dialog" | |
31 CURRENT_LABEL = "current_label" | |
32 HIDDEN = "hidden" | |
33 | |
34 | |
35 class InvalidXMLUI(Exception): | |
36 pass | |
37 | |
38 | |
39 class ClassNotRegistedError(Exception): | |
40 pass | |
41 | |
42 | |
43 # FIXME: this method is duplicated in frontends.tools.xmlui.get_text | |
44 def get_text(node): | |
45 """Get child text nodes | |
46 @param node: dom Node | |
47 @return: joined unicode text of all nodes | |
48 | |
49 """ | |
50 data = [] | |
51 for child in node.childNodes: | |
52 if child.nodeType == child.TEXT_NODE: | |
53 data.append(child.wholeText) | |
54 return "".join(data) | |
55 | |
56 | |
57 class Widget(object): | |
58 """base Widget""" | |
59 | |
60 pass | |
61 | |
62 | |
63 class EmptyWidget(Widget): | |
64 """Just a placeholder widget""" | |
65 | |
66 pass | |
67 | |
68 | |
69 class TextWidget(Widget): | |
70 """Non interactive text""" | |
71 | |
72 pass | |
73 | |
74 | |
75 class LabelWidget(Widget): | |
76 """Non interactive text""" | |
77 | |
78 pass | |
79 | |
80 | |
81 class JidWidget(Widget): | |
82 """Jabber ID""" | |
83 | |
84 pass | |
85 | |
86 | |
87 class DividerWidget(Widget): | |
88 """Separator""" | |
89 | |
90 pass | |
91 | |
92 | |
93 class StringWidget(Widget): | |
94 """Input widget wich require a string | |
95 | |
96 often called Edit in toolkits | |
97 """ | |
98 | |
99 pass | |
100 | |
101 | |
102 class JidInputWidget(Widget): | |
103 """Input widget wich require a string | |
104 | |
105 often called Edit in toolkits | |
106 """ | |
107 | |
108 pass | |
109 | |
110 | |
111 class PasswordWidget(Widget): | |
112 """Input widget with require a masked string""" | |
113 | |
114 pass | |
115 | |
116 | |
117 class TextBoxWidget(Widget): | |
118 """Input widget with require a long, possibly multilines string | |
119 | |
120 often called TextArea in toolkits | |
121 """ | |
122 | |
123 pass | |
124 | |
125 | |
126 class XHTMLBoxWidget(Widget): | |
127 """Input widget specialised in XHTML editing, | |
128 | |
129 a WYSIWYG or specialised editor is expected | |
130 """ | |
131 | |
132 pass | |
133 | |
134 | |
135 class BoolWidget(Widget): | |
136 """Input widget with require a boolean value | |
137 often called CheckBox in toolkits | |
138 """ | |
139 | |
140 pass | |
141 | |
142 | |
143 class IntWidget(Widget): | |
144 """Input widget with require an integer""" | |
145 | |
146 pass | |
147 | |
148 | |
149 class ButtonWidget(Widget): | |
150 """A clickable widget""" | |
151 | |
152 pass | |
153 | |
154 | |
155 class ListWidget(Widget): | |
156 """A widget able to show/choose one or several strings in a list""" | |
157 | |
158 pass | |
159 | |
160 | |
161 class JidsListWidget(Widget): | |
162 """A widget able to show/choose one or several strings in a list""" | |
163 | |
164 pass | |
165 | |
166 | |
167 class Container(Widget): | |
168 """Widget which can contain other ones with a specific layout""" | |
169 | |
170 @classmethod | |
171 def _xmlui_adapt(cls, instance): | |
172 """Make cls as instance.__class__ | |
173 | |
174 cls must inherit from original instance class | |
175 Usefull when you get a class from UI toolkit | |
176 """ | |
177 assert instance.__class__ in cls.__bases__ | |
178 instance.__class__ = type(cls.__name__, cls.__bases__, dict(cls.__dict__)) | |
179 | |
180 | |
181 class PairsContainer(Container): | |
182 """Widgets are disposed in rows of two (usually label/input)""" | |
183 | |
184 pass | |
185 | |
186 | |
187 class LabelContainer(Container): | |
188 """Widgets are associated with label or empty widget""" | |
189 | |
190 pass | |
191 | |
192 | |
193 class TabsContainer(Container): | |
194 """A container which several other containers in tabs | |
195 | |
196 Often called Notebook in toolkits | |
197 """ | |
198 | |
199 pass | |
200 | |
201 | |
202 class VerticalContainer(Container): | |
203 """Widgets are disposed vertically""" | |
204 | |
205 pass | |
206 | |
207 | |
208 class AdvancedListContainer(Container): | |
209 """Widgets are disposed in rows with advaned features""" | |
210 | |
211 pass | |
212 | |
213 | |
214 class Dialog(object): | |
215 """base dialog""" | |
216 | |
217 def __init__(self, _xmlui_parent): | |
218 self._xmlui_parent = _xmlui_parent | |
219 | |
220 def _xmlui_validated(self, data=None): | |
221 if data is None: | |
222 data = {} | |
223 self._xmlui_set_data(C.XMLUI_STATUS_VALIDATED, data) | |
224 self._xmlui_submit(data) | |
225 | |
226 def _xmlui_cancelled(self): | |
227 data = {C.XMLUI_DATA_CANCELLED: C.BOOL_TRUE} | |
228 self._xmlui_set_data(C.XMLUI_STATUS_CANCELLED, data) | |
229 self._xmlui_submit(data) | |
230 | |
231 def _xmlui_submit(self, data): | |
232 if self._xmlui_parent.submit_id is None: | |
233 log.debug(_("Nothing to submit")) | |
234 else: | |
235 self._xmlui_parent.submit(data) | |
236 | |
237 def _xmlui_set_data(self, status, data): | |
238 pass | |
239 | |
240 | |
241 class MessageDialog(Dialog): | |
242 """Dialog with a OK/Cancel type configuration""" | |
243 | |
244 pass | |
245 | |
246 | |
247 class NoteDialog(Dialog): | |
248 """Short message which doesn't need user confirmation to disappear""" | |
249 | |
250 pass | |
251 | |
252 | |
253 class ConfirmDialog(Dialog): | |
254 """Dialog with a OK/Cancel type configuration""" | |
255 | |
256 def _xmlui_set_data(self, status, data): | |
257 if status == C.XMLUI_STATUS_VALIDATED: | |
258 data[C.XMLUI_DATA_ANSWER] = C.BOOL_TRUE | |
259 elif status == C.XMLUI_STATUS_CANCELLED: | |
260 data[C.XMLUI_DATA_ANSWER] = C.BOOL_FALSE | |
261 | |
262 | |
263 class FileDialog(Dialog): | |
264 """Dialog with a OK/Cancel type configuration""" | |
265 | |
266 pass | |
267 | |
268 | |
269 class XMLUIBase(object): | |
270 """Base class to construct SàT XML User Interface | |
271 | |
272 This class must not be instancied directly | |
273 """ | |
274 | |
275 def __init__(self, host, parsed_dom, title=None, flags=None, callback=None, | |
276 profile=C.PROF_KEY_NONE): | |
277 """Initialise the XMLUI instance | |
278 | |
279 @param host: %(doc_host)s | |
280 @param parsed_dom: main parsed dom | |
281 @param title: force the title, or use XMLUI one if None | |
282 @param flags: list of string which can be: | |
283 - NO_CANCEL: the UI can't be cancelled | |
284 - FROM_BACKEND: the UI come from backend (i.e. it's not the direct result of | |
285 user operation) | |
286 @param callback(callable, None): if not None, will be used with action_launch: | |
287 - if None is used, default behaviour will be used (closing the dialog and | |
288 calling host.action_manager) | |
289 - if a callback is provided, it will be used instead, so you'll have to manage | |
290 dialog closing or new xmlui to display, or other action (you can call | |
291 host.action_manager) | |
292 The callback will have data, callback_id and profile as arguments | |
293 """ | |
294 self.host = host | |
295 top = parsed_dom.documentElement | |
296 self.session_id = top.getAttribute("session_id") or None | |
297 self.submit_id = top.getAttribute("submit") or None | |
298 self.xmlui_title = title or top.getAttribute("title") or "" | |
299 self.hidden = {} | |
300 if flags is None: | |
301 flags = [] | |
302 self.flags = flags | |
303 self.callback = callback or self._default_cb | |
304 self.profile = profile | |
305 | |
306 @property | |
307 def user_action(self): | |
308 return "FROM_BACKEND" not in self.flags | |
309 | |
310 def _default_cb(self, data, cb_id, profile): | |
311 # TODO: when XMLUI updates will be managed, the _xmlui_close | |
312 # must be called only if there is no update | |
313 self._xmlui_close() | |
314 self.host.action_manager(data, profile=profile) | |
315 | |
316 def _is_attr_set(self, name, node): | |
317 """Return widget boolean attribute status | |
318 | |
319 @param name: name of the attribute (e.g. "read_only") | |
320 @param node: Node instance | |
321 @return (bool): True if widget's attribute is set (C.BOOL_TRUE) | |
322 """ | |
323 read_only = node.getAttribute(name) or C.BOOL_FALSE | |
324 return read_only.lower().strip() == C.BOOL_TRUE | |
325 | |
326 def _get_child_node(self, node, name): | |
327 """Return the first child node with the given name | |
328 | |
329 @param node: Node instance | |
330 @param name: name of the wanted node | |
331 | |
332 @return: The found element or None | |
333 """ | |
334 for child in node.childNodes: | |
335 if child.nodeName == name: | |
336 return child | |
337 return None | |
338 | |
339 def submit(self, data): | |
340 self._xmlui_close() | |
341 if self.submit_id is None: | |
342 raise ValueError("Can't submit is self.submit_id is not set") | |
343 if "session_id" in data: | |
344 raise ValueError( | |
345 "session_id must no be used in data, it is automaticaly filled with " | |
346 "self.session_id if present" | |
347 ) | |
348 if self.session_id is not None: | |
349 data["session_id"] = self.session_id | |
350 self._xmlui_launch_action(self.submit_id, data) | |
351 | |
352 def _xmlui_launch_action(self, action_id, data): | |
353 self.host.action_launch( | |
354 action_id, data, callback=self.callback, profile=self.profile | |
355 ) | |
356 | |
357 def _xmlui_close(self): | |
358 """Close the window/popup/... where the constructor XMLUI is | |
359 | |
360 this method must be overrided | |
361 """ | |
362 raise NotImplementedError | |
363 | |
364 | |
365 class ValueGetter(object): | |
366 """dict like object which return values of widgets""" | |
367 # FIXME: widget which can keep multiple values are not handled | |
368 | |
369 def __init__(self, widgets, attr="value"): | |
370 self.attr = attr | |
371 self.widgets = widgets | |
372 | |
373 def __getitem__(self, name): | |
374 return getattr(self.widgets[name], self.attr) | |
375 | |
376 def __getattr__(self, name): | |
377 return self.__getitem__(name) | |
378 | |
379 def keys(self): | |
380 return list(self.widgets.keys()) | |
381 | |
382 def items(self): | |
383 for name, widget in self.widgets.items(): | |
384 try: | |
385 value = widget.value | |
386 except AttributeError: | |
387 try: | |
388 value = list(widget.values) | |
389 except AttributeError: | |
390 continue | |
391 yield name, value | |
392 | |
393 | |
394 class XMLUIPanel(XMLUIBase): | |
395 """XMLUI Panel | |
396 | |
397 New frontends can inherit this class to easily implement XMLUI | |
398 @property widget_factory: factory to create frontend-specific widgets | |
399 @property dialog_factory: factory to create frontend-specific dialogs | |
400 """ | |
401 | |
402 widget_factory = None | |
403 | |
404 def __init__(self, host, parsed_dom, title=None, flags=None, callback=None, | |
405 ignore=None, whitelist=None, profile=C.PROF_KEY_NONE): | |
406 """ | |
407 | |
408 @param title(unicode, None): title of the | |
409 @property widgets(dict): widget name => widget map | |
410 @property widget_value(ValueGetter): retrieve widget value from it's name | |
411 """ | |
412 super(XMLUIPanel, self).__init__( | |
413 host, parsed_dom, title=title, flags=flags, callback=callback, profile=profile | |
414 ) | |
415 self.ctrl_list = {} # input widget, used mainly for forms | |
416 self.widgets = {} # allow to access any named widgets | |
417 self.widget_value = ValueGetter(self.widgets) | |
418 self._main_cont = None | |
419 if ignore is None: | |
420 ignore = [] | |
421 self._ignore = ignore | |
422 if whitelist is not None: | |
423 if ignore: | |
424 raise exceptions.InternalError( | |
425 "ignore and whitelist must not be used at the same time" | |
426 ) | |
427 self._whitelist = whitelist | |
428 else: | |
429 self._whitelist = None | |
430 self.construct_ui(parsed_dom) | |
431 | |
432 @staticmethod | |
433 def escape(name): | |
434 """Return escaped name for forms""" | |
435 return "%s%s" % (C.SAT_FORM_PREFIX, name) | |
436 | |
437 @property | |
438 def main_cont(self): | |
439 return self._main_cont | |
440 | |
441 @property | |
442 def values(self): | |
443 """Dict of all widgets values""" | |
444 return dict(self.widget_value.items()) | |
445 | |
446 @main_cont.setter | |
447 def main_cont(self, value): | |
448 if self._main_cont is not None: | |
449 raise ValueError(_("XMLUI can have only one main container")) | |
450 self._main_cont = value | |
451 | |
452 def _parse_childs(self, _xmlui_parent, current_node, wanted=("container",), data=None): | |
453 """Recursively parse childNodes of an element | |
454 | |
455 @param _xmlui_parent: widget container with '_xmlui_append' method | |
456 @param current_node: element from which childs will be parsed | |
457 @param wanted: list of tag names that can be present in the childs to be SàT XMLUI | |
458 compliant | |
459 @param data(None, dict): additionnal data which are needed in some cases | |
460 """ | |
461 for node in current_node.childNodes: | |
462 if data is None: | |
463 data = {} | |
464 if wanted and not node.nodeName in wanted: | |
465 raise InvalidXMLUI("Unexpected node: [%s]" % node.nodeName) | |
466 | |
467 if node.nodeName == "container": | |
468 type_ = node.getAttribute("type") | |
469 if _xmlui_parent is self and type_ not in ("vertical", "tabs"): | |
470 # main container is not a VerticalContainer and we want one, | |
471 # so we create one to wrap it | |
472 _xmlui_parent = self.widget_factory.createVerticalContainer(self) | |
473 self.main_cont = _xmlui_parent | |
474 if type_ == "tabs": | |
475 cont = self.widget_factory.createTabsContainer(_xmlui_parent) | |
476 self._parse_childs(_xmlui_parent, node, ("tab",), {"tabs_cont": cont}) | |
477 elif type_ == "vertical": | |
478 cont = self.widget_factory.createVerticalContainer(_xmlui_parent) | |
479 self._parse_childs(cont, node, ("widget", "container")) | |
480 elif type_ == "pairs": | |
481 cont = self.widget_factory.createPairsContainer(_xmlui_parent) | |
482 self._parse_childs(cont, node, ("widget", "container")) | |
483 elif type_ == "label": | |
484 cont = self.widget_factory.createLabelContainer(_xmlui_parent) | |
485 self._parse_childs( | |
486 # FIXME: the "None" value for CURRENT_LABEL doesn't seem | |
487 # used or even useful, it should probably be removed | |
488 # and all "is not None" tests for it should be removed too | |
489 # to be checked for 0.8 | |
490 cont, node, ("widget", "container"), {CURRENT_LABEL: None} | |
491 ) | |
492 elif type_ == "advanced_list": | |
493 try: | |
494 columns = int(node.getAttribute("columns")) | |
495 except (TypeError, ValueError): | |
496 raise exceptions.DataError("Invalid columns") | |
497 selectable = node.getAttribute("selectable") or "no" | |
498 auto_index = node.getAttribute("auto_index") == C.BOOL_TRUE | |
499 data = {"index": 0} if auto_index else None | |
500 cont = self.widget_factory.createAdvancedListContainer( | |
501 _xmlui_parent, columns, selectable | |
502 ) | |
503 callback_id = node.getAttribute("callback") or None | |
504 if callback_id is not None: | |
505 if selectable == "no": | |
506 raise ValueError( | |
507 "can't have selectable=='no' and callback_id at the same time" | |
508 ) | |
509 cont._xmlui_callback_id = callback_id | |
510 cont._xmlui_on_select(self.on_adv_list_select) | |
511 | |
512 self._parse_childs(cont, node, ("row",), data) | |
513 else: | |
514 log.warning(_("Unknown container [%s], using default one") % type_) | |
515 cont = self.widget_factory.createVerticalContainer(_xmlui_parent) | |
516 self._parse_childs(cont, node, ("widget", "container")) | |
517 try: | |
518 xmluiAppend = _xmlui_parent._xmlui_append | |
519 except ( | |
520 AttributeError, | |
521 TypeError, | |
522 ): # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError | |
523 if _xmlui_parent is self: | |
524 self.main_cont = cont | |
525 else: | |
526 raise Exception( | |
527 _("Internal Error, container has not _xmlui_append method") | |
528 ) | |
529 else: | |
530 xmluiAppend(cont) | |
531 | |
532 elif node.nodeName == "tab": | |
533 name = node.getAttribute("name") | |
534 label = node.getAttribute("label") | |
535 selected = C.bool(node.getAttribute("selected") or C.BOOL_FALSE) | |
536 if not name or not "tabs_cont" in data: | |
537 raise InvalidXMLUI | |
538 if self.type == "param": | |
539 self._current_category = ( | |
540 name | |
541 ) # XXX: awful hack because params need category and we don't keep parent | |
542 tab_cont = data["tabs_cont"] | |
543 new_tab = tab_cont._xmlui_add_tab(label or name, selected) | |
544 self._parse_childs(new_tab, node, ("widget", "container")) | |
545 | |
546 elif node.nodeName == "row": | |
547 try: | |
548 index = str(data["index"]) | |
549 except KeyError: | |
550 index = node.getAttribute("index") or None | |
551 else: | |
552 data["index"] += 1 | |
553 _xmlui_parent._xmlui_add_row(index) | |
554 self._parse_childs(_xmlui_parent, node, ("widget", "container")) | |
555 | |
556 elif node.nodeName == "widget": | |
557 name = node.getAttribute("name") | |
558 if name and ( | |
559 name in self._ignore | |
560 or self._whitelist is not None | |
561 and name not in self._whitelist | |
562 ): | |
563 # current widget is ignored, but there may be already a label | |
564 if CURRENT_LABEL in data: | |
565 curr_label = data.pop(CURRENT_LABEL) | |
566 if curr_label is not None: | |
567 # if so, we remove it from parent | |
568 _xmlui_parent._xmlui_remove(curr_label) | |
569 continue | |
570 type_ = node.getAttribute("type") | |
571 value_elt = self._get_child_node(node, "value") | |
572 if value_elt is not None: | |
573 value = get_text(value_elt) | |
574 else: | |
575 value = ( | |
576 node.getAttribute("value") if node.hasAttribute("value") else "" | |
577 ) | |
578 if type_ == "empty": | |
579 ctrl = self.widget_factory.createEmptyWidget(_xmlui_parent) | |
580 if CURRENT_LABEL in data: | |
581 data[CURRENT_LABEL] = None | |
582 elif type_ == "text": | |
583 ctrl = self.widget_factory.createTextWidget(_xmlui_parent, value) | |
584 elif type_ == "label": | |
585 ctrl = self.widget_factory.createLabelWidget(_xmlui_parent, value) | |
586 data[CURRENT_LABEL] = ctrl | |
587 elif type_ == "hidden": | |
588 if name in self.hidden: | |
589 raise exceptions.ConflictError("Conflict on hidden value with " | |
590 "name {name}".format(name=name)) | |
591 self.hidden[name] = value | |
592 continue | |
593 elif type_ == "jid": | |
594 ctrl = self.widget_factory.createJidWidget(_xmlui_parent, value) | |
595 elif type_ == "divider": | |
596 style = node.getAttribute("style") or "line" | |
597 ctrl = self.widget_factory.createDividerWidget(_xmlui_parent, style) | |
598 elif type_ == "string": | |
599 ctrl = self.widget_factory.createStringWidget( | |
600 _xmlui_parent, value, self._is_attr_set("read_only", node) | |
601 ) | |
602 self.ctrl_list[name] = {"type": type_, "control": ctrl} | |
603 elif type_ == "jid_input": | |
604 ctrl = self.widget_factory.createJidInputWidget( | |
605 _xmlui_parent, value, self._is_attr_set("read_only", node) | |
606 ) | |
607 self.ctrl_list[name] = {"type": type_, "control": ctrl} | |
608 elif type_ == "password": | |
609 ctrl = self.widget_factory.createPasswordWidget( | |
610 _xmlui_parent, value, self._is_attr_set("read_only", node) | |
611 ) | |
612 self.ctrl_list[name] = {"type": type_, "control": ctrl} | |
613 elif type_ == "textbox": | |
614 ctrl = self.widget_factory.createTextBoxWidget( | |
615 _xmlui_parent, value, self._is_attr_set("read_only", node) | |
616 ) | |
617 self.ctrl_list[name] = {"type": type_, "control": ctrl} | |
618 elif type_ == "xhtmlbox": | |
619 ctrl = self.widget_factory.createXHTMLBoxWidget( | |
620 _xmlui_parent, value, self._is_attr_set("read_only", node) | |
621 ) | |
622 self.ctrl_list[name] = {"type": type_, "control": ctrl} | |
623 elif type_ == "bool": | |
624 ctrl = self.widget_factory.createBoolWidget( | |
625 _xmlui_parent, | |
626 value == C.BOOL_TRUE, | |
627 self._is_attr_set("read_only", node), | |
628 ) | |
629 self.ctrl_list[name] = {"type": type_, "control": ctrl} | |
630 elif type_ == "int": | |
631 ctrl = self.widget_factory.createIntWidget( | |
632 _xmlui_parent, value, self._is_attr_set("read_only", node) | |
633 ) | |
634 self.ctrl_list[name] = {"type": type_, "control": ctrl} | |
635 elif type_ == "list": | |
636 style = [] if node.getAttribute("multi") == "yes" else ["single"] | |
637 for attr in ("noselect", "extensible", "reducible", "inline"): | |
638 if node.getAttribute(attr) == "yes": | |
639 style.append(attr) | |
640 _options = [ | |
641 (option.getAttribute("value"), option.getAttribute("label")) | |
642 for option in node.getElementsByTagName("option") | |
643 ] | |
644 _selected = [ | |
645 option.getAttribute("value") | |
646 for option in node.getElementsByTagName("option") | |
647 if option.getAttribute("selected") == C.BOOL_TRUE | |
648 ] | |
649 ctrl = self.widget_factory.createListWidget( | |
650 _xmlui_parent, _options, _selected, style | |
651 ) | |
652 self.ctrl_list[name] = {"type": type_, "control": ctrl} | |
653 elif type_ == "jids_list": | |
654 style = [] | |
655 jids = [get_text(jid_) for jid_ in node.getElementsByTagName("jid")] | |
656 ctrl = self.widget_factory.createJidsListWidget( | |
657 _xmlui_parent, jids, style | |
658 ) | |
659 self.ctrl_list[name] = {"type": type_, "control": ctrl} | |
660 elif type_ == "button": | |
661 callback_id = node.getAttribute("callback") | |
662 ctrl = self.widget_factory.createButtonWidget( | |
663 _xmlui_parent, value, self.on_button_press | |
664 ) | |
665 ctrl._xmlui_param_id = ( | |
666 callback_id, | |
667 [ | |
668 field.getAttribute("name") | |
669 for field in node.getElementsByTagName("field_back") | |
670 ], | |
671 ) | |
672 else: | |
673 log.error( | |
674 _("FIXME FIXME FIXME: widget type [%s] is not implemented") | |
675 % type_ | |
676 ) | |
677 raise NotImplementedError( | |
678 _("FIXME FIXME FIXME: type [%s] is not implemented") % type_ | |
679 ) | |
680 | |
681 if name: | |
682 self.widgets[name] = ctrl | |
683 | |
684 if self.type == "param" and type_ not in ("text", "button"): | |
685 try: | |
686 ctrl._xmlui_on_change(self.on_param_change) | |
687 ctrl._param_category = self._current_category | |
688 except ( | |
689 AttributeError, | |
690 TypeError, | |
691 ): # XXX: TypeError is here because pyjamas raise a TypeError instead | |
692 # of an AttributeError | |
693 if not isinstance( | |
694 ctrl, (EmptyWidget, TextWidget, LabelWidget, JidWidget) | |
695 ): | |
696 log.warning(_("No change listener on [%s]") % ctrl) | |
697 | |
698 elif type_ != "text": | |
699 callback = node.getAttribute("internal_callback") or None | |
700 if callback: | |
701 fields = [ | |
702 field.getAttribute("name") | |
703 for field in node.getElementsByTagName("internal_field") | |
704 ] | |
705 cb_data = self.get_internal_callback_data(callback, node) | |
706 ctrl._xmlui_param_internal = (callback, fields, cb_data) | |
707 if type_ == "button": | |
708 ctrl._xmlui_on_click(self.on_change_internal) | |
709 else: | |
710 ctrl._xmlui_on_change(self.on_change_internal) | |
711 | |
712 ctrl._xmlui_name = name | |
713 _xmlui_parent._xmlui_append(ctrl) | |
714 if CURRENT_LABEL in data and not isinstance(ctrl, LabelWidget): | |
715 curr_label = data.pop(CURRENT_LABEL) | |
716 if curr_label is not None: | |
717 # this key is set in LabelContainer, when present | |
718 # we can associate the label with the widget it is labelling | |
719 curr_label._xmlui_for_name = name | |
720 | |
721 else: | |
722 raise NotImplementedError(_("Unknown tag [%s]") % node.nodeName) | |
723 | |
724 def construct_ui(self, parsed_dom, post_treat=None): | |
725 """Actually construct the UI | |
726 | |
727 @param parsed_dom: main parsed dom | |
728 @param post_treat: frontend specific treatments to do once the UI is constructed | |
729 @return: constructed widget | |
730 """ | |
731 top = parsed_dom.documentElement | |
732 self.type = top.getAttribute("type") | |
733 if top.nodeName != "sat_xmlui" or not self.type in [ | |
734 "form", | |
735 "param", | |
736 "window", | |
737 "popup", | |
738 ]: | |
739 raise InvalidXMLUI | |
740 | |
741 if self.type == "param": | |
742 self.param_changed = set() | |
743 | |
744 self._parse_childs(self, parsed_dom.documentElement) | |
745 | |
746 if post_treat is not None: | |
747 post_treat() | |
748 | |
749 def _xmlui_set_param(self, name, value, category): | |
750 self.host.bridge.param_set(name, value, category, profile_key=self.profile) | |
751 | |
752 ##EVENTS## | |
753 | |
754 def on_param_change(self, ctrl): | |
755 """Called when type is param and a widget to save is modified | |
756 | |
757 @param ctrl: widget modified | |
758 """ | |
759 assert self.type == "param" | |
760 self.param_changed.add(ctrl) | |
761 | |
762 def on_adv_list_select(self, ctrl): | |
763 data = {} | |
764 widgets = ctrl._xmlui_get_selected_widgets() | |
765 for wid in widgets: | |
766 try: | |
767 name = self.escape(wid._xmlui_name) | |
768 value = wid._xmlui_get_value() | |
769 data[name] = value | |
770 except ( | |
771 AttributeError, | |
772 TypeError, | |
773 ): # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError | |
774 pass | |
775 idx = ctrl._xmlui_get_selected_index() | |
776 if idx is not None: | |
777 data["index"] = idx | |
778 callback_id = ctrl._xmlui_callback_id | |
779 if callback_id is None: | |
780 log.info(_("No callback_id found")) | |
781 return | |
782 self._xmlui_launch_action(callback_id, data) | |
783 | |
784 def on_button_press(self, button): | |
785 """Called when an XMLUI button is clicked | |
786 | |
787 Launch the action associated to the button | |
788 @param button: the button clicked | |
789 """ | |
790 callback_id, fields = button._xmlui_param_id | |
791 if not callback_id: # the button is probably bound to an internal action | |
792 return | |
793 data = {} | |
794 for field in fields: | |
795 escaped = self.escape(field) | |
796 ctrl = self.ctrl_list[field] | |
797 if isinstance(ctrl["control"], ListWidget): | |
798 data[escaped] = "\t".join(ctrl["control"]._xmlui_get_selected_values()) | |
799 else: | |
800 data[escaped] = ctrl["control"]._xmlui_get_value() | |
801 self._xmlui_launch_action(callback_id, data) | |
802 | |
803 def on_change_internal(self, ctrl): | |
804 """Called when a widget that has been bound to an internal callback is changed. | |
805 | |
806 This is used to perform some UI actions without communicating with the backend. | |
807 See sat.tools.xml_tools.Widget.set_internal_callback for more details. | |
808 @param ctrl: widget modified | |
809 """ | |
810 action, fields, data = ctrl._xmlui_param_internal | |
811 if action not in ("copy", "move", "groups_of_contact"): | |
812 raise NotImplementedError( | |
813 _("FIXME: XMLUI internal action [%s] is not implemented") % action | |
814 ) | |
815 | |
816 def copy_move(source, target): | |
817 """Depending of 'action' value, copy or move from source to target.""" | |
818 if isinstance(target, ListWidget): | |
819 if isinstance(source, ListWidget): | |
820 values = source._xmlui_get_selected_values() | |
821 else: | |
822 values = [source._xmlui_get_value()] | |
823 if action == "move": | |
824 source._xmlui_set_value("") | |
825 values = [value for value in values if value] | |
826 if values: | |
827 target._xmlui_add_values(values, select=True) | |
828 else: | |
829 if isinstance(source, ListWidget): | |
830 value = ", ".join(source._xmlui_get_selected_values()) | |
831 else: | |
832 value = source._xmlui_get_value() | |
833 if action == "move": | |
834 source._xmlui_set_value("") | |
835 target._xmlui_set_value(value) | |
836 | |
837 def groups_of_contact(source, target): | |
838 """Select in target the groups of the contact which is selected in source.""" | |
839 assert isinstance(source, ListWidget) | |
840 assert isinstance(target, ListWidget) | |
841 try: | |
842 contact_jid_s = source._xmlui_get_selected_values()[0] | |
843 except IndexError: | |
844 return | |
845 target._xmlui_select_values(data[contact_jid_s]) | |
846 pass | |
847 | |
848 source = None | |
849 for field in fields: | |
850 widget = self.ctrl_list[field]["control"] | |
851 if not source: | |
852 source = widget | |
853 continue | |
854 if action in ("copy", "move"): | |
855 copy_move(source, widget) | |
856 elif action == "groups_of_contact": | |
857 groups_of_contact(source, widget) | |
858 source = None | |
859 | |
860 def get_internal_callback_data(self, action, node): | |
861 """Retrieve from node the data needed to perform given action. | |
862 | |
863 @param action (string): a value from the one that can be passed to the | |
864 'callback' parameter of sat.tools.xml_tools.Widget.set_internal_callback | |
865 @param node (DOM Element): the node of the widget that triggers the callback | |
866 """ | |
867 # TODO: it would be better to not have a specific way to retrieve | |
868 # data for each action, but instead to have a generic method to | |
869 # extract any kind of data structure from the 'internal_data' element. | |
870 | |
871 try: # data is stored in the first 'internal_data' element of the node | |
872 data_elts = node.getElementsByTagName("internal_data")[0].childNodes | |
873 except IndexError: | |
874 return None | |
875 data = {} | |
876 if ( | |
877 action == "groups_of_contact" | |
878 ): # return a dict(key: string, value: list[string]) | |
879 for elt in data_elts: | |
880 jid_s = elt.getAttribute("name") | |
881 data[jid_s] = [] | |
882 for value_elt in elt.childNodes: | |
883 data[jid_s].append(value_elt.getAttribute("name")) | |
884 return data | |
885 | |
886 def on_form_submitted(self, ignore=None): | |
887 """An XMLUI form has been submited | |
888 | |
889 call the submit action associated with this form | |
890 """ | |
891 selected_values = [] | |
892 for ctrl_name in self.ctrl_list: | |
893 escaped = self.escape(ctrl_name) | |
894 ctrl = self.ctrl_list[ctrl_name] | |
895 if isinstance(ctrl["control"], ListWidget): | |
896 selected_values.append( | |
897 (escaped, "\t".join(ctrl["control"]._xmlui_get_selected_values())) | |
898 ) | |
899 else: | |
900 selected_values.append((escaped, ctrl["control"]._xmlui_get_value())) | |
901 data = dict(selected_values) | |
902 for key, value in self.hidden.items(): | |
903 data[self.escape(key)] = value | |
904 | |
905 if self.submit_id is not None: | |
906 self.submit(data) | |
907 else: | |
908 log.warning( | |
909 _("The form data is not sent back, the type is not managed properly") | |
910 ) | |
911 self._xmlui_close() | |
912 | |
913 def on_form_cancelled(self, *__): | |
914 """Called when a form is cancelled""" | |
915 log.debug(_("Cancelling form")) | |
916 if self.submit_id is not None: | |
917 data = {C.XMLUI_DATA_CANCELLED: C.BOOL_TRUE} | |
918 self.submit(data) | |
919 else: | |
920 log.warning( | |
921 _("The form data is not sent back, the type is not managed properly") | |
922 ) | |
923 self._xmlui_close() | |
924 | |
925 def on_save_params(self, ignore=None): | |
926 """Params are saved, we send them to backend | |
927 | |
928 self.type must be param | |
929 """ | |
930 assert self.type == "param" | |
931 for ctrl in self.param_changed: | |
932 if isinstance(ctrl, ListWidget): | |
933 value = "\t".join(ctrl._xmlui_get_selected_values()) | |
934 else: | |
935 value = ctrl._xmlui_get_value() | |
936 param_name = ctrl._xmlui_name.split(C.SAT_PARAM_SEPARATOR)[1] | |
937 self._xmlui_set_param(param_name, value, ctrl._param_category) | |
938 | |
939 self._xmlui_close() | |
940 | |
941 def show(self, *args, **kwargs): | |
942 pass | |
943 | |
944 | |
945 class AIOXMLUIPanel(XMLUIPanel): | |
946 """Asyncio compatible version of XMLUIPanel""" | |
947 | |
948 async def on_form_submitted(self, ignore=None): | |
949 """An XMLUI form has been submited | |
950 | |
951 call the submit action associated with this form | |
952 """ | |
953 selected_values = [] | |
954 for ctrl_name in self.ctrl_list: | |
955 escaped = self.escape(ctrl_name) | |
956 ctrl = self.ctrl_list[ctrl_name] | |
957 if isinstance(ctrl["control"], ListWidget): | |
958 selected_values.append( | |
959 (escaped, "\t".join(ctrl["control"]._xmlui_get_selected_values())) | |
960 ) | |
961 else: | |
962 selected_values.append((escaped, ctrl["control"]._xmlui_get_value())) | |
963 data = dict(selected_values) | |
964 for key, value in self.hidden.items(): | |
965 data[self.escape(key)] = value | |
966 | |
967 if self.submit_id is not None: | |
968 await self.submit(data) | |
969 else: | |
970 log.warning( | |
971 _("The form data is not sent back, the type is not managed properly") | |
972 ) | |
973 self._xmlui_close() | |
974 | |
975 async def on_form_cancelled(self, *__): | |
976 """Called when a form is cancelled""" | |
977 log.debug(_("Cancelling form")) | |
978 if self.submit_id is not None: | |
979 data = {C.XMLUI_DATA_CANCELLED: C.BOOL_TRUE} | |
980 await self.submit(data) | |
981 else: | |
982 log.warning( | |
983 _("The form data is not sent back, the type is not managed properly") | |
984 ) | |
985 self._xmlui_close() | |
986 | |
987 async def submit(self, data): | |
988 self._xmlui_close() | |
989 if self.submit_id is None: | |
990 raise ValueError("Can't submit is self.submit_id is not set") | |
991 if "session_id" in data: | |
992 raise ValueError( | |
993 "session_id must no be used in data, it is automaticaly filled with " | |
994 "self.session_id if present" | |
995 ) | |
996 if self.session_id is not None: | |
997 data["session_id"] = self.session_id | |
998 await self._xmlui_launch_action(self.submit_id, data) | |
999 | |
1000 async def _xmlui_launch_action(self, action_id, data): | |
1001 await self.host.action_launch( | |
1002 action_id, data, callback=self.callback, profile=self.profile | |
1003 ) | |
1004 | |
1005 | |
1006 class XMLUIDialog(XMLUIBase): | |
1007 dialog_factory = None | |
1008 | |
1009 def __init__( | |
1010 self, | |
1011 host, | |
1012 parsed_dom, | |
1013 title=None, | |
1014 flags=None, | |
1015 callback=None, | |
1016 ignore=None, | |
1017 whitelist=None, | |
1018 profile=C.PROF_KEY_NONE, | |
1019 ): | |
1020 super(XMLUIDialog, self).__init__( | |
1021 host, parsed_dom, title=title, flags=flags, callback=callback, profile=profile | |
1022 ) | |
1023 top = parsed_dom.documentElement | |
1024 dlg_elt = self._get_child_node(top, "dialog") | |
1025 if dlg_elt is None: | |
1026 raise ValueError("Invalid XMLUI: no Dialog element found !") | |
1027 dlg_type = dlg_elt.getAttribute("type") or C.XMLUI_DIALOG_MESSAGE | |
1028 try: | |
1029 mess_elt = self._get_child_node(dlg_elt, C.XMLUI_DATA_MESS) | |
1030 message = get_text(mess_elt) | |
1031 except ( | |
1032 TypeError, | |
1033 AttributeError, | |
1034 ): # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError | |
1035 message = "" | |
1036 level = dlg_elt.getAttribute(C.XMLUI_DATA_LVL) or C.XMLUI_DATA_LVL_INFO | |
1037 | |
1038 if dlg_type == C.XMLUI_DIALOG_MESSAGE: | |
1039 self.dlg = self.dialog_factory.createMessageDialog( | |
1040 self, self.xmlui_title, message, level | |
1041 ) | |
1042 elif dlg_type == C.XMLUI_DIALOG_NOTE: | |
1043 self.dlg = self.dialog_factory.createNoteDialog( | |
1044 self, self.xmlui_title, message, level | |
1045 ) | |
1046 elif dlg_type == C.XMLUI_DIALOG_CONFIRM: | |
1047 try: | |
1048 buttons_elt = self._get_child_node(dlg_elt, "buttons") | |
1049 buttons_set = ( | |
1050 buttons_elt.getAttribute("set") or C.XMLUI_DATA_BTNS_SET_DEFAULT | |
1051 ) | |
1052 except ( | |
1053 TypeError, | |
1054 AttributeError, | |
1055 ): # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError | |
1056 buttons_set = C.XMLUI_DATA_BTNS_SET_DEFAULT | |
1057 self.dlg = self.dialog_factory.createConfirmDialog( | |
1058 self, self.xmlui_title, message, level, buttons_set | |
1059 ) | |
1060 elif dlg_type == C.XMLUI_DIALOG_FILE: | |
1061 try: | |
1062 file_elt = self._get_child_node(dlg_elt, "file") | |
1063 filetype = file_elt.getAttribute("type") or C.XMLUI_DATA_FILETYPE_DEFAULT | |
1064 except ( | |
1065 TypeError, | |
1066 AttributeError, | |
1067 ): # XXX: TypeError is here because pyjamas raise a TypeError instead of an AttributeError | |
1068 filetype = C.XMLUI_DATA_FILETYPE_DEFAULT | |
1069 self.dlg = self.dialog_factory.createFileDialog( | |
1070 self, self.xmlui_title, message, level, filetype | |
1071 ) | |
1072 else: | |
1073 raise ValueError("Unknown dialog type [%s]" % dlg_type) | |
1074 | |
1075 def show(self): | |
1076 self.dlg._xmlui_show() | |
1077 | |
1078 def _xmlui_close(self): | |
1079 self.dlg._xmlui_close() | |
1080 | |
1081 | |
1082 def register_class(type_, class_): | |
1083 """Register the class to use with the factory | |
1084 | |
1085 @param type_: one of: | |
1086 CLASS_PANEL: classical XMLUI interface | |
1087 CLASS_DIALOG: XMLUI dialog | |
1088 @param class_: the class to use to instanciate given type | |
1089 """ | |
1090 # TODO: remove this method, as there are seme use cases where different XMLUI | |
1091 # classes can be used in the same frontend, so a global value is not good | |
1092 assert type_ in (CLASS_PANEL, CLASS_DIALOG) | |
1093 log.warning("register_class for XMLUI is deprecated, please use partial with " | |
1094 "xmlui.create and class_map instead") | |
1095 if type_ in _class_map: | |
1096 log.debug(_("XMLUI class already registered for {type_}, ignoring").format( | |
1097 type_=type_)) | |
1098 return | |
1099 | |
1100 _class_map[type_] = class_ | |
1101 | |
1102 | |
1103 def create(host, xml_data, title=None, flags=None, dom_parse=None, dom_free=None, | |
1104 callback=None, ignore=None, whitelist=None, class_map=None, | |
1105 profile=C.PROF_KEY_NONE): | |
1106 """ | |
1107 @param dom_parse: methode equivalent to minidom.parseString (but which must manage unicode), or None to use default one | |
1108 @param dom_free: method used to free the parsed DOM | |
1109 @param ignore(list[unicode], None): name of widgets to ignore | |
1110 widgets with name in this list and their label will be ignored | |
1111 @param whitelist(list[unicode], None): name of widgets to keep | |
1112 when not None, only widgets in this list and their label will be kept | |
1113 mutually exclusive with ignore | |
1114 """ | |
1115 if class_map is None: | |
1116 class_map = _class_map | |
1117 if dom_parse is None: | |
1118 from xml.dom import minidom | |
1119 | |
1120 dom_parse = lambda xml_data: minidom.parseString(xml_data.encode("utf-8")) | |
1121 dom_free = lambda parsed_dom: parsed_dom.unlink() | |
1122 else: | |
1123 dom_parse = dom_parse | |
1124 dom_free = dom_free or (lambda parsed_dom: None) | |
1125 parsed_dom = dom_parse(xml_data) | |
1126 top = parsed_dom.documentElement | |
1127 ui_type = top.getAttribute("type") | |
1128 try: | |
1129 if ui_type != C.XMLUI_DIALOG: | |
1130 cls = class_map[CLASS_PANEL] | |
1131 else: | |
1132 cls = class_map[CLASS_DIALOG] | |
1133 except KeyError: | |
1134 raise ClassNotRegistedError( | |
1135 _("You must register classes with register_class before creating a XMLUI") | |
1136 ) | |
1137 | |
1138 xmlui = cls( | |
1139 host, | |
1140 parsed_dom, | |
1141 title=title, | |
1142 flags=flags, | |
1143 callback=callback, | |
1144 ignore=ignore, | |
1145 whitelist=whitelist, | |
1146 profile=profile, | |
1147 ) | |
1148 dom_free(parsed_dom) | |
1149 return xmlui |