view libervia/cli/xmlui_manager.py @ 4137:10979b5c305a

install: add test dependencies to dev env
author Goffi <goffi@goffi.org>
date Wed, 01 Nov 2023 13:57:08 +0100
parents 47401850dec6
children
line wrap: on
line source

#!/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},
)