comparison libervia/cli/xmlui_manager.py @ 4075:47401850dec6

refactoring: rename `libervia.frontends.jp` to `libervia.cli`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 14:54:26 +0200
parents libervia/frontends/jp/xmlui_manager.py@26b7ed2817da
children
comparison
equal deleted inserted replaced
4074:26b7ed2817da 4075:47401850dec6
1 #!/usr/bin/env python3
2
3
4 # Libervia CLI
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 functools import partial
21 from libervia.backend.core.log import getLogger
22 from libervia.frontends.tools import xmlui as xmlui_base
23 from libervia.cli.constants import Const as C
24 from libervia.backend.tools.common.ansi import ANSI as A
25 from libervia.backend.core.i18n import _
26 from libervia.backend.tools.common import data_format
27
28 log = getLogger(__name__)
29
30 # workflow constants
31
32 SUBMIT = "SUBMIT" # submit form
33
34
35 ## Widgets ##
36
37
38 class Base(object):
39 """Base for Widget and Container"""
40
41 type = None
42 _root = None
43
44 def __init__(self, xmlui_parent):
45 self.xmlui_parent = xmlui_parent
46 self.host = self.xmlui_parent.host
47
48 @property
49 def root(self):
50 """retrieve main XMLUI parent class"""
51 if self._root is not None:
52 return self._root
53 root = self
54 while not isinstance(root, xmlui_base.XMLUIBase):
55 root = root.xmlui_parent
56 self._root = root
57 return root
58
59 def disp(self, *args, **kwargs):
60 self.host.disp(*args, **kwargs)
61
62
63 class Widget(Base):
64 category = "widget"
65 enabled = True
66
67 @property
68 def name(self):
69 return self._xmlui_name
70
71 async def show(self):
72 """display current widget
73
74 must be overriden by subclasses
75 """
76 raise NotImplementedError(self.__class__)
77
78 def verbose_name(self, elems=None, value=None):
79 """add name in color to the elements
80
81 helper method to display name which can then be used to automate commands
82 elems is only modified if verbosity is > 0
83 @param elems(list[unicode], None): elements to display
84 None to display name directly
85 @param value(unicode, None): value to show
86 use self.name if None
87 """
88 if value is None:
89 value = self.name
90 if self.host.verbosity:
91 to_disp = [
92 A.FG_MAGENTA,
93 " " if elems else "",
94 "({})".format(value),
95 A.RESET,
96 ]
97 if elems is None:
98 self.host.disp(A.color(*to_disp))
99 else:
100 elems.extend(to_disp)
101
102
103 class ValueWidget(Widget):
104 def __init__(self, xmlui_parent, value):
105 super(ValueWidget, self).__init__(xmlui_parent)
106 self.value = value
107
108 @property
109 def values(self):
110 return [self.value]
111
112
113 class InputWidget(ValueWidget):
114 def __init__(self, xmlui_parent, value, read_only=False):
115 super(InputWidget, self).__init__(xmlui_parent, value)
116 self.read_only = read_only
117
118 def _xmlui_get_value(self):
119 return self.value
120
121
122 class OptionsWidget(Widget):
123 def __init__(self, xmlui_parent, options, selected, style):
124 super(OptionsWidget, self).__init__(xmlui_parent)
125 self.options = options
126 self.selected = selected
127 self.style = style
128
129 @property
130 def values(self):
131 return self.selected
132
133 @values.setter
134 def values(self, values):
135 self.selected = values
136
137 @property
138 def value(self):
139 return self.selected[0]
140
141 @value.setter
142 def value(self, value):
143 self.selected = [value]
144
145 def _xmlui_select_value(self, value):
146 self.value = value
147
148 def _xmlui_select_values(self, values):
149 self.values = values
150
151 def _xmlui_get_selected_values(self):
152 return self.values
153
154 @property
155 def labels(self):
156 """return only labels from self.items"""
157 for value, label in self.items:
158 yield label
159
160 @property
161 def items(self):
162 """return suitable items, according to style"""
163 no_select = self.no_select
164 for value, label in self.options:
165 if no_select or value in self.selected:
166 yield value, label
167
168 @property
169 def inline(self):
170 return "inline" in self.style
171
172 @property
173 def no_select(self):
174 return "noselect" in self.style
175
176
177 class EmptyWidget(xmlui_base.EmptyWidget, Widget):
178 def __init__(self, xmlui_parent):
179 Widget.__init__(self, xmlui_parent)
180
181 async def show(self):
182 self.host.disp("")
183
184
185 class TextWidget(xmlui_base.TextWidget, ValueWidget):
186 type = "text"
187
188 async def show(self):
189 self.host.disp(self.value)
190
191
192 class LabelWidget(xmlui_base.LabelWidget, ValueWidget):
193 type = "label"
194
195 @property
196 def for_name(self):
197 try:
198 return self._xmlui_for_name
199 except AttributeError:
200 return None
201
202 async def show(self, end="\n", ansi=""):
203 """show label
204
205 @param end(str): same as for [LiberviaCli.disp]
206 @param ansi(unicode): ansi escape code to print before label
207 """
208 self.disp(A.color(ansi, self.value), end=end)
209
210
211 class JidWidget(xmlui_base.JidWidget, TextWidget):
212 type = "jid"
213
214
215 class StringWidget(xmlui_base.StringWidget, InputWidget):
216 type = "string"
217
218 async def show(self):
219 if self.read_only or self.root.read_only:
220 self.disp(self.value)
221 else:
222 elems = []
223 self.verbose_name(elems)
224 if self.value:
225 elems.append(_("(enter: {value})").format(value=self.value))
226 elems.extend([C.A_HEADER, "> "])
227 value = await self.host.ainput(A.color(*elems))
228 if value:
229 #  TODO: empty value should be possible
230 # an escape key should be used for default instead of enter with empty value
231 self.value = value
232
233
234 class JidInputWidget(xmlui_base.JidInputWidget, StringWidget):
235 type = "jid_input"
236
237
238 class PasswordWidget(xmlui_base.PasswordWidget, StringWidget):
239 type = "password"
240
241
242 class TextBoxWidget(xmlui_base.TextWidget, StringWidget):
243 type = "textbox"
244 # TODO: use a more advanced input method
245
246 async def show(self):
247 self.verbose_name()
248 if self.read_only or self.root.read_only:
249 self.disp(self.value)
250 else:
251 if self.value:
252 self.disp(
253 A.color(C.A_HEADER, "↓ current value ↓\n", A.FG_CYAN, self.value, "")
254 )
255
256 values = []
257 while True:
258 try:
259 if not values:
260 line = await self.host.ainput(
261 A.color(C.A_HEADER, "[Ctrl-D to finish]> ")
262 )
263 else:
264 line = await self.host.ainput()
265 values.append(line)
266 except EOFError:
267 break
268
269 self.value = "\n".join(values).rstrip()
270
271
272 class XHTMLBoxWidget(xmlui_base.XHTMLBoxWidget, StringWidget):
273 type = "xhtmlbox"
274
275 async def show(self):
276 # FIXME: we use bridge in a blocking way as permitted by python-dbus
277 # this only for now to make it simpler, it must be refactored to use async
278 # when libervia-cli will be fully async (expected for 0.8)
279 self.value = await self.host.bridge.syntax_convert(
280 self.value, C.SYNTAX_XHTML, "markdown", False, self.host.profile
281 )
282 await super(XHTMLBoxWidget, self).show()
283
284
285 class ListWidget(xmlui_base.ListWidget, OptionsWidget):
286 type = "list"
287 # TODO: handle flags, notably multi
288
289 async def show(self):
290 if self.root.values_only:
291 for value in self.values:
292 self.disp(self.value)
293 return
294 if not self.options:
295 return
296
297 # list display
298 self.verbose_name()
299
300 for idx, (value, label) in enumerate(self.options):
301 elems = []
302 if not self.root.read_only:
303 elems.extend([C.A_SUBHEADER, str(idx), A.RESET, ": "])
304 elems.append(label)
305 self.verbose_name(elems, value)
306 self.disp(A.color(*elems))
307
308 if self.root.read_only:
309 return
310
311 if len(self.options) == 1:
312 # we have only one option, no need to ask
313 self.value = self.options[0][0]
314 return
315
316 #  we ask use to choose an option
317 choice = None
318 limit_max = len(self.options) - 1
319 while choice is None or choice < 0 or choice > limit_max:
320 choice = await self.host.ainput(
321 A.color(
322 C.A_HEADER,
323 _("your choice (0-{limit_max}): ").format(limit_max=limit_max),
324 )
325 )
326 try:
327 choice = int(choice)
328 except ValueError:
329 choice = None
330 self.value = self.options[choice][0]
331 self.disp("")
332
333
334 class BoolWidget(xmlui_base.BoolWidget, InputWidget):
335 type = "bool"
336
337 async def show(self):
338 disp_true = A.color(A.FG_GREEN, "TRUE")
339 disp_false = A.color(A.FG_RED, "FALSE")
340 if self.read_only or self.root.read_only:
341 self.disp(disp_true if self.value else disp_false)
342 else:
343 self.disp(
344 A.color(
345 C.A_HEADER, "0: ", disp_false, A.RESET, " *" if not self.value else ""
346 )
347 )
348 self.disp(
349 A.color(C.A_HEADER, "1: ", disp_true, A.RESET, " *" if self.value else "")
350 )
351 choice = None
352 while choice not in ("0", "1"):
353 elems = [C.A_HEADER, _("your choice (0,1): ")]
354 self.verbose_name(elems)
355 choice = await self.host.ainput(A.color(*elems))
356 self.value = bool(int(choice))
357 self.disp("")
358
359 def _xmlui_get_value(self):
360 return C.bool_const(self.value)
361
362 ## Containers ##
363
364
365 class Container(Base):
366 category = "container"
367
368 def __init__(self, xmlui_parent):
369 super(Container, self).__init__(xmlui_parent)
370 self.children = []
371
372 def __iter__(self):
373 return iter(self.children)
374
375 def _xmlui_append(self, widget):
376 self.children.append(widget)
377
378 def _xmlui_remove(self, widget):
379 self.children.remove(widget)
380
381 async def show(self):
382 for child in self.children:
383 await child.show()
384
385
386 class VerticalContainer(xmlui_base.VerticalContainer, Container):
387 type = "vertical"
388
389
390 class PairsContainer(xmlui_base.PairsContainer, Container):
391 type = "pairs"
392
393
394 class LabelContainer(xmlui_base.PairsContainer, Container):
395 type = "label"
396
397 async def show(self):
398 for child in self.children:
399 end = "\n"
400 # we check linked widget type
401 # to see if we want the label on the same line or not
402 if child.type == "label":
403 for_name = child.for_name
404 if for_name:
405 for_widget = self.root.widgets[for_name]
406 wid_type = for_widget.type
407 if self.root.values_only or wid_type in (
408 "text",
409 "string",
410 "jid_input",
411 ):
412 end = " "
413 elif wid_type == "bool" and for_widget.read_only:
414 end = " "
415 await child.show(end=end, ansi=A.FG_CYAN)
416 else:
417 await child.show()
418
419 ## Dialogs ##
420
421
422 class Dialog(object):
423 def __init__(self, xmlui_parent):
424 self.xmlui_parent = xmlui_parent
425 self.host = self.xmlui_parent.host
426
427 def disp(self, *args, **kwargs):
428 self.host.disp(*args, **kwargs)
429
430 async def show(self):
431 """display current dialog
432
433 must be overriden by subclasses
434 """
435 raise NotImplementedError(self.__class__)
436
437
438 class MessageDialog(xmlui_base.MessageDialog, Dialog):
439 def __init__(self, xmlui_parent, title, message, level):
440 Dialog.__init__(self, xmlui_parent)
441 xmlui_base.MessageDialog.__init__(self, xmlui_parent)
442 self.title, self.message, self.level = title, message, level
443
444 async def show(self):
445 # TODO: handle level
446 if self.title:
447 self.disp(A.color(C.A_HEADER, self.title))
448 self.disp(self.message)
449
450
451 class NoteDialog(xmlui_base.NoteDialog, Dialog):
452 def __init__(self, xmlui_parent, title, message, level):
453 Dialog.__init__(self, xmlui_parent)
454 xmlui_base.NoteDialog.__init__(self, xmlui_parent)
455 self.title, self.message, self.level = title, message, level
456
457 async def show(self):
458 # TODO: handle title
459 error = self.level in (C.XMLUI_DATA_LVL_WARNING, C.XMLUI_DATA_LVL_ERROR)
460 if self.level == C.XMLUI_DATA_LVL_WARNING:
461 msg = A.color(C.A_WARNING, self.message)
462 elif self.level == C.XMLUI_DATA_LVL_ERROR:
463 msg = A.color(C.A_FAILURE, self.message)
464 else:
465 msg = self.message
466 self.disp(msg, error=error)
467
468
469 class ConfirmDialog(xmlui_base.ConfirmDialog, Dialog):
470 def __init__(self, xmlui_parent, title, message, level, buttons_set):
471 Dialog.__init__(self, xmlui_parent)
472 xmlui_base.ConfirmDialog.__init__(self, xmlui_parent)
473 self.title, self.message, self.level, self.buttons_set = (
474 title,
475 message,
476 level,
477 buttons_set,
478 )
479
480 async def show(self):
481 # TODO: handle buttons_set and level
482 self.disp(self.message)
483 if self.title:
484 self.disp(A.color(C.A_HEADER, self.title))
485 input_ = None
486 while input_ not in ("y", "n"):
487 input_ = await self.host.ainput(f"{self.message} (y/n)? ")
488 input_ = input_.lower()
489 if input_ == "y":
490 self._xmlui_validated()
491 else:
492 self._xmlui_cancelled()
493
494 ## Factory ##
495
496
497 class WidgetFactory(object):
498 def __getattr__(self, attr):
499 if attr.startswith("create"):
500 cls = globals()[attr[6:]]
501 return cls
502
503
504 class XMLUIPanel(xmlui_base.AIOXMLUIPanel):
505 widget_factory = WidgetFactory()
506 _actions = 0 # use to keep track of bridge's action_launch calls
507 read_only = False
508 values_only = False
509 workflow = None
510 _submit_cb = None
511
512 def __init__(
513 self,
514 host,
515 parsed_dom,
516 title=None,
517 flags=None,
518 callback=None,
519 ignore=None,
520 whitelist=None,
521 profile=None,
522 ):
523 xmlui_base.XMLUIPanel.__init__(
524 self,
525 host,
526 parsed_dom,
527 title=title,
528 flags=flags,
529 ignore=ignore,
530 whitelist=whitelist,
531 profile=host.profile,
532 )
533 self.submitted = False
534
535 @property
536 def command(self):
537 return self.host.command
538
539 def disp(self, *args, **kwargs):
540 self.host.disp(*args, **kwargs)
541
542 async def show(self, workflow=None, read_only=False, values_only=False):
543 """display the panel
544
545 @param workflow(list, None): command to execute if not None
546 put here for convenience, the main workflow is the class attribute
547 (because workflow can continue in subclasses)
548 command are a list of consts or lists:
549 - SUBMIT is the only constant so far, it submits the XMLUI
550 - list must contain widget name/widget value to fill
551 @param read_only(bool): if True, don't request values
552 @param values_only(bool): if True, only show select values (imply read_only)
553 """
554 self.read_only = read_only
555 self.values_only = values_only
556 if self.values_only:
557 self.read_only = True
558 if workflow:
559 XMLUIPanel.workflow = workflow
560 if XMLUIPanel.workflow:
561 await self.run_workflow()
562 else:
563 await self.main_cont.show()
564
565 async def run_workflow(self):
566 """loop into workflow commands and execute commands
567
568 SUBMIT will interrupt workflow (which will be continue on callback)
569 @param workflow(list): same as [show]
570 """
571 workflow = XMLUIPanel.workflow
572 while True:
573 try:
574 cmd = workflow.pop(0)
575 except IndexError:
576 break
577 if cmd == SUBMIT:
578 await self.on_form_submitted()
579 self.submit_id = None # avoid double submit
580 return
581 elif isinstance(cmd, list):
582 name, value = cmd
583 widget = self.widgets[name]
584 if widget.type == "bool":
585 value = C.bool(value)
586 widget.value = value
587 await self.show()
588
589 async def submit_form(self, callback=None):
590 XMLUIPanel._submit_cb = callback
591 await self.on_form_submitted()
592
593 async def on_form_submitted(self, ignore=None):
594 # self.submitted is a Q&D workaround to avoid
595 # double submit when a workflow is set
596 if self.submitted:
597 return
598 self.submitted = True
599 await super(XMLUIPanel, self).on_form_submitted(ignore)
600
601 def _xmlui_close(self):
602 pass
603
604 async def _launch_action_cb(self, data):
605 XMLUIPanel._actions -= 1
606 assert XMLUIPanel._actions >= 0
607 if "xmlui" in data:
608 xmlui_raw = data["xmlui"]
609 xmlui = create(self.host, xmlui_raw)
610 await xmlui.show()
611 if xmlui.submit_id:
612 await xmlui.on_form_submitted()
613 # TODO: handle data other than XMLUI
614 if not XMLUIPanel._actions:
615 if self._submit_cb is None:
616 self.host.quit()
617 else:
618 self._submit_cb()
619
620 async def _xmlui_launch_action(self, action_id, data):
621 XMLUIPanel._actions += 1
622 try:
623 data = data_format.deserialise(
624 await self.host.bridge.action_launch(
625 action_id,
626 data_format.serialise(data),
627 self.profile,
628 )
629 )
630 except Exception as e:
631 self.disp(f"can't launch XMLUI action: {e}", error=True)
632 self.host.quit(C.EXIT_BRIDGE_ERRBACK)
633 else:
634 await self._launch_action_cb(data)
635
636
637 class XMLUIDialog(xmlui_base.XMLUIDialog):
638 type = "dialog"
639 dialog_factory = WidgetFactory()
640 read_only = False
641
642 async def show(self, __=None):
643 await self.dlg.show()
644
645 def _xmlui_close(self):
646 pass
647
648
649 create = partial(
650 xmlui_base.create,
651 class_map={xmlui_base.CLASS_PANEL: XMLUIPanel, xmlui_base.CLASS_DIALOG: XMLUIDialog},
652 )