diff 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
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/cli/xmlui_manager.py	Fri Jun 02 14:54:26 2023 +0200
@@ -0,0 +1,652 @@
+#!/usr/bin/env python3
+
+
+# Libervia CLI
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from functools import partial
+from libervia.backend.core.log import getLogger
+from libervia.frontends.tools import xmlui as xmlui_base
+from libervia.cli.constants import Const as C
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+
+log = getLogger(__name__)
+
+# workflow constants
+
+SUBMIT = "SUBMIT"  # submit form
+
+
+## Widgets ##
+
+
+class Base(object):
+    """Base for Widget and Container"""
+
+    type = None
+    _root = None
+
+    def __init__(self, xmlui_parent):
+        self.xmlui_parent = xmlui_parent
+        self.host = self.xmlui_parent.host
+
+    @property
+    def root(self):
+        """retrieve main XMLUI parent class"""
+        if self._root is not None:
+            return self._root
+        root = self
+        while not isinstance(root, xmlui_base.XMLUIBase):
+            root = root.xmlui_parent
+        self._root = root
+        return root
+
+    def disp(self, *args, **kwargs):
+        self.host.disp(*args, **kwargs)
+
+
+class Widget(Base):
+    category = "widget"
+    enabled = True
+
+    @property
+    def name(self):
+        return self._xmlui_name
+
+    async def show(self):
+        """display current widget
+
+        must be overriden by subclasses
+        """
+        raise NotImplementedError(self.__class__)
+
+    def verbose_name(self, elems=None, value=None):
+        """add name in color to the elements
+
+        helper method to display name which can then be used to automate commands
+        elems is only modified if verbosity is > 0
+        @param elems(list[unicode], None): elements to display
+            None to display name directly
+        @param value(unicode, None): value to show
+            use self.name if None
+        """
+        if value is None:
+            value = self.name
+        if self.host.verbosity:
+            to_disp = [
+                A.FG_MAGENTA,
+                " " if elems else "",
+                "({})".format(value),
+                A.RESET,
+            ]
+            if elems is None:
+                self.host.disp(A.color(*to_disp))
+            else:
+                elems.extend(to_disp)
+
+
+class ValueWidget(Widget):
+    def __init__(self, xmlui_parent, value):
+        super(ValueWidget, self).__init__(xmlui_parent)
+        self.value = value
+
+    @property
+    def values(self):
+        return [self.value]
+
+
+class InputWidget(ValueWidget):
+    def __init__(self, xmlui_parent, value, read_only=False):
+        super(InputWidget, self).__init__(xmlui_parent, value)
+        self.read_only = read_only
+
+    def _xmlui_get_value(self):
+        return self.value
+
+
+class OptionsWidget(Widget):
+    def __init__(self, xmlui_parent, options, selected, style):
+        super(OptionsWidget, self).__init__(xmlui_parent)
+        self.options = options
+        self.selected = selected
+        self.style = style
+
+    @property
+    def values(self):
+        return self.selected
+
+    @values.setter
+    def values(self, values):
+        self.selected = values
+
+    @property
+    def value(self):
+        return self.selected[0]
+
+    @value.setter
+    def value(self, value):
+        self.selected = [value]
+
+    def _xmlui_select_value(self, value):
+        self.value = value
+
+    def _xmlui_select_values(self, values):
+        self.values = values
+
+    def _xmlui_get_selected_values(self):
+        return self.values
+
+    @property
+    def labels(self):
+        """return only labels from self.items"""
+        for value, label in self.items:
+            yield label
+
+    @property
+    def items(self):
+        """return suitable items, according to style"""
+        no_select = self.no_select
+        for value, label in self.options:
+            if no_select or value in self.selected:
+                yield value, label
+
+    @property
+    def inline(self):
+        return "inline" in self.style
+
+    @property
+    def no_select(self):
+        return "noselect" in self.style
+
+
+class EmptyWidget(xmlui_base.EmptyWidget, Widget):
+    def __init__(self, xmlui_parent):
+        Widget.__init__(self, xmlui_parent)
+
+    async def show(self):
+        self.host.disp("")
+
+
+class TextWidget(xmlui_base.TextWidget, ValueWidget):
+    type = "text"
+
+    async def show(self):
+        self.host.disp(self.value)
+
+
+class LabelWidget(xmlui_base.LabelWidget, ValueWidget):
+    type = "label"
+
+    @property
+    def for_name(self):
+        try:
+            return self._xmlui_for_name
+        except AttributeError:
+            return None
+
+    async def show(self, end="\n", ansi=""):
+        """show label
+
+        @param end(str): same as for [LiberviaCli.disp]
+        @param ansi(unicode): ansi escape code to print before label
+        """
+        self.disp(A.color(ansi, self.value), end=end)
+
+
+class JidWidget(xmlui_base.JidWidget, TextWidget):
+    type = "jid"
+
+
+class StringWidget(xmlui_base.StringWidget, InputWidget):
+    type = "string"
+
+    async def show(self):
+        if self.read_only or self.root.read_only:
+            self.disp(self.value)
+        else:
+            elems = []
+            self.verbose_name(elems)
+            if self.value:
+                elems.append(_("(enter: {value})").format(value=self.value))
+            elems.extend([C.A_HEADER, "> "])
+            value = await self.host.ainput(A.color(*elems))
+            if value:
+                #  TODO: empty value should be possible
+                #       an escape key should be used for default instead of enter with empty value
+                self.value = value
+
+
+class JidInputWidget(xmlui_base.JidInputWidget, StringWidget):
+    type = "jid_input"
+
+
+class PasswordWidget(xmlui_base.PasswordWidget, StringWidget):
+    type = "password"
+
+
+class TextBoxWidget(xmlui_base.TextWidget, StringWidget):
+    type = "textbox"
+    # TODO: use a more advanced input method
+
+    async def show(self):
+        self.verbose_name()
+        if self.read_only or self.root.read_only:
+            self.disp(self.value)
+        else:
+            if self.value:
+                self.disp(
+                    A.color(C.A_HEADER, "↓ current value ↓\n", A.FG_CYAN, self.value, "")
+                )
+
+            values = []
+            while True:
+                try:
+                    if not values:
+                        line = await self.host.ainput(
+                            A.color(C.A_HEADER, "[Ctrl-D to finish]> ")
+                        )
+                    else:
+                        line = await self.host.ainput()
+                    values.append(line)
+                except EOFError:
+                    break
+
+            self.value = "\n".join(values).rstrip()
+
+
+class XHTMLBoxWidget(xmlui_base.XHTMLBoxWidget, StringWidget):
+    type = "xhtmlbox"
+
+    async def show(self):
+        # FIXME: we use bridge in a blocking way as permitted by python-dbus
+        #        this only for now to make it simpler, it must be refactored to use async
+        #        when libervia-cli will be fully async (expected for 0.8)
+        self.value = await self.host.bridge.syntax_convert(
+            self.value, C.SYNTAX_XHTML, "markdown", False, self.host.profile
+        )
+        await super(XHTMLBoxWidget, self).show()
+
+
+class ListWidget(xmlui_base.ListWidget, OptionsWidget):
+    type = "list"
+    # TODO: handle flags, notably multi
+
+    async def show(self):
+        if self.root.values_only:
+            for value in self.values:
+                self.disp(self.value)
+                return
+        if not self.options:
+            return
+
+            # list display
+        self.verbose_name()
+
+        for idx, (value, label) in enumerate(self.options):
+            elems = []
+            if not self.root.read_only:
+                elems.extend([C.A_SUBHEADER, str(idx), A.RESET, ": "])
+            elems.append(label)
+            self.verbose_name(elems, value)
+            self.disp(A.color(*elems))
+
+        if self.root.read_only:
+            return
+
+        if len(self.options) == 1:
+            # we have only one option, no need to ask
+            self.value = self.options[0][0]
+            return
+
+            #  we ask use to choose an option
+        choice = None
+        limit_max = len(self.options) - 1
+        while choice is None or choice < 0 or choice > limit_max:
+            choice = await self.host.ainput(
+                A.color(
+                    C.A_HEADER,
+                    _("your choice (0-{limit_max}): ").format(limit_max=limit_max),
+                )
+            )
+            try:
+                choice = int(choice)
+            except ValueError:
+                choice = None
+        self.value = self.options[choice][0]
+        self.disp("")
+
+
+class BoolWidget(xmlui_base.BoolWidget, InputWidget):
+    type = "bool"
+
+    async def show(self):
+        disp_true = A.color(A.FG_GREEN, "TRUE")
+        disp_false = A.color(A.FG_RED, "FALSE")
+        if self.read_only or self.root.read_only:
+            self.disp(disp_true if self.value else disp_false)
+        else:
+            self.disp(
+                A.color(
+                    C.A_HEADER, "0: ", disp_false, A.RESET, " *" if not self.value else ""
+                )
+            )
+            self.disp(
+                A.color(C.A_HEADER, "1: ", disp_true, A.RESET, " *" if self.value else "")
+            )
+            choice = None
+            while choice not in ("0", "1"):
+                elems = [C.A_HEADER, _("your choice (0,1): ")]
+                self.verbose_name(elems)
+                choice = await self.host.ainput(A.color(*elems))
+            self.value = bool(int(choice))
+            self.disp("")
+
+    def _xmlui_get_value(self):
+        return C.bool_const(self.value)
+
+        ## Containers ##
+
+
+class Container(Base):
+    category = "container"
+
+    def __init__(self, xmlui_parent):
+        super(Container, self).__init__(xmlui_parent)
+        self.children = []
+
+    def __iter__(self):
+        return iter(self.children)
+
+    def _xmlui_append(self, widget):
+        self.children.append(widget)
+
+    def _xmlui_remove(self, widget):
+        self.children.remove(widget)
+
+    async def show(self):
+        for child in self.children:
+            await child.show()
+
+
+class VerticalContainer(xmlui_base.VerticalContainer, Container):
+    type = "vertical"
+
+
+class PairsContainer(xmlui_base.PairsContainer, Container):
+    type = "pairs"
+
+
+class LabelContainer(xmlui_base.PairsContainer, Container):
+    type = "label"
+
+    async def show(self):
+        for child in self.children:
+            end = "\n"
+            # we check linked widget type
+            # to see if we want the label on the same line or not
+            if child.type == "label":
+                for_name = child.for_name
+                if for_name:
+                    for_widget = self.root.widgets[for_name]
+                    wid_type = for_widget.type
+                    if self.root.values_only or wid_type in (
+                        "text",
+                        "string",
+                        "jid_input",
+                    ):
+                        end = " "
+                    elif wid_type == "bool" and for_widget.read_only:
+                        end = " "
+                await child.show(end=end, ansi=A.FG_CYAN)
+            else:
+                await child.show()
+
+                ## Dialogs ##
+
+
+class Dialog(object):
+    def __init__(self, xmlui_parent):
+        self.xmlui_parent = xmlui_parent
+        self.host = self.xmlui_parent.host
+
+    def disp(self, *args, **kwargs):
+        self.host.disp(*args, **kwargs)
+
+    async def show(self):
+        """display current dialog
+
+        must be overriden by subclasses
+        """
+        raise NotImplementedError(self.__class__)
+
+
+class MessageDialog(xmlui_base.MessageDialog, Dialog):
+    def __init__(self, xmlui_parent, title, message, level):
+        Dialog.__init__(self, xmlui_parent)
+        xmlui_base.MessageDialog.__init__(self, xmlui_parent)
+        self.title, self.message, self.level = title, message, level
+
+    async def show(self):
+        # TODO: handle level
+        if self.title:
+            self.disp(A.color(C.A_HEADER, self.title))
+        self.disp(self.message)
+
+
+class NoteDialog(xmlui_base.NoteDialog, Dialog):
+    def __init__(self, xmlui_parent, title, message, level):
+        Dialog.__init__(self, xmlui_parent)
+        xmlui_base.NoteDialog.__init__(self, xmlui_parent)
+        self.title, self.message, self.level = title, message, level
+
+    async def show(self):
+        # TODO: handle title
+        error = self.level in (C.XMLUI_DATA_LVL_WARNING, C.XMLUI_DATA_LVL_ERROR)
+        if self.level == C.XMLUI_DATA_LVL_WARNING:
+            msg = A.color(C.A_WARNING, self.message)
+        elif self.level == C.XMLUI_DATA_LVL_ERROR:
+            msg = A.color(C.A_FAILURE, self.message)
+        else:
+            msg = self.message
+        self.disp(msg, error=error)
+
+
+class ConfirmDialog(xmlui_base.ConfirmDialog, Dialog):
+    def __init__(self, xmlui_parent, title, message, level, buttons_set):
+        Dialog.__init__(self, xmlui_parent)
+        xmlui_base.ConfirmDialog.__init__(self, xmlui_parent)
+        self.title, self.message, self.level, self.buttons_set = (
+            title,
+            message,
+            level,
+            buttons_set,
+        )
+
+    async def show(self):
+        # TODO: handle buttons_set and level
+        self.disp(self.message)
+        if self.title:
+            self.disp(A.color(C.A_HEADER, self.title))
+        input_ = None
+        while input_ not in ("y", "n"):
+            input_ = await self.host.ainput(f"{self.message} (y/n)? ")
+            input_ = input_.lower()
+        if input_ == "y":
+            self._xmlui_validated()
+        else:
+            self._xmlui_cancelled()
+
+            ## Factory ##
+
+
+class WidgetFactory(object):
+    def __getattr__(self, attr):
+        if attr.startswith("create"):
+            cls = globals()[attr[6:]]
+            return cls
+
+
+class XMLUIPanel(xmlui_base.AIOXMLUIPanel):
+    widget_factory = WidgetFactory()
+    _actions = 0  # use to keep track of bridge's action_launch calls
+    read_only = False
+    values_only = False
+    workflow = None
+    _submit_cb = None
+
+    def __init__(
+        self,
+        host,
+        parsed_dom,
+        title=None,
+        flags=None,
+        callback=None,
+        ignore=None,
+        whitelist=None,
+        profile=None,
+    ):
+        xmlui_base.XMLUIPanel.__init__(
+            self,
+            host,
+            parsed_dom,
+            title=title,
+            flags=flags,
+            ignore=ignore,
+            whitelist=whitelist,
+            profile=host.profile,
+        )
+        self.submitted = False
+
+    @property
+    def command(self):
+        return self.host.command
+
+    def disp(self, *args, **kwargs):
+        self.host.disp(*args, **kwargs)
+
+    async def show(self, workflow=None, read_only=False, values_only=False):
+        """display the panel
+
+        @param workflow(list, None): command to execute if not None
+            put here for convenience, the main workflow is the class attribute
+            (because workflow can continue in subclasses)
+            command are a list of consts or lists:
+                - SUBMIT is the only constant so far, it submits the XMLUI
+                - list must contain widget name/widget value to fill
+        @param read_only(bool): if True, don't request values
+        @param values_only(bool): if True, only show select values (imply read_only)
+        """
+        self.read_only = read_only
+        self.values_only = values_only
+        if self.values_only:
+            self.read_only = True
+        if workflow:
+            XMLUIPanel.workflow = workflow
+        if XMLUIPanel.workflow:
+            await self.run_workflow()
+        else:
+            await self.main_cont.show()
+
+    async def run_workflow(self):
+        """loop into workflow commands and execute commands
+
+        SUBMIT will interrupt workflow (which will be continue on callback)
+        @param workflow(list): same as [show]
+        """
+        workflow = XMLUIPanel.workflow
+        while True:
+            try:
+                cmd = workflow.pop(0)
+            except IndexError:
+                break
+            if cmd == SUBMIT:
+                await self.on_form_submitted()
+                self.submit_id = None  # avoid double submit
+                return
+            elif isinstance(cmd, list):
+                name, value = cmd
+                widget = self.widgets[name]
+                if widget.type == "bool":
+                    value = C.bool(value)
+                widget.value = value
+        await self.show()
+
+    async def submit_form(self, callback=None):
+        XMLUIPanel._submit_cb = callback
+        await self.on_form_submitted()
+
+    async def on_form_submitted(self, ignore=None):
+        # self.submitted is a Q&D workaround to avoid
+        # double submit when a workflow is set
+        if self.submitted:
+            return
+        self.submitted = True
+        await super(XMLUIPanel, self).on_form_submitted(ignore)
+
+    def _xmlui_close(self):
+        pass
+
+    async def _launch_action_cb(self, data):
+        XMLUIPanel._actions -= 1
+        assert XMLUIPanel._actions >= 0
+        if "xmlui" in data:
+            xmlui_raw = data["xmlui"]
+            xmlui = create(self.host, xmlui_raw)
+            await xmlui.show()
+            if xmlui.submit_id:
+                await xmlui.on_form_submitted()
+                # TODO: handle data other than XMLUI
+        if not XMLUIPanel._actions:
+            if self._submit_cb is None:
+                self.host.quit()
+            else:
+                self._submit_cb()
+
+    async def _xmlui_launch_action(self, action_id, data):
+        XMLUIPanel._actions += 1
+        try:
+            data = data_format.deserialise(
+                await self.host.bridge.action_launch(
+                    action_id,
+                    data_format.serialise(data),
+                    self.profile,
+                )
+            )
+        except Exception as e:
+            self.disp(f"can't launch XMLUI action: {e}", error=True)
+            self.host.quit(C.EXIT_BRIDGE_ERRBACK)
+        else:
+            await self._launch_action_cb(data)
+
+
+class XMLUIDialog(xmlui_base.XMLUIDialog):
+    type = "dialog"
+    dialog_factory = WidgetFactory()
+    read_only = False
+
+    async def show(self, __=None):
+        await self.dlg.show()
+
+    def _xmlui_close(self):
+        pass
+
+
+create = partial(
+    xmlui_base.create,
+    class_map={xmlui_base.CLASS_PANEL: XMLUIPanel, xmlui_base.CLASS_DIALOG: XMLUIDialog},
+)