view libervia/web/pages/_browser/dialog.py @ 1625:698eaabfca0e

browser (chat): side/panel and keyword handling: - `dialog.Modal` can now be used with a `position` argument. The default `center` keeps the old behaviour of modal in the middle of the screen, while using one of the direction makes the modal appear from top/bottom/right/left with an animation. The closing cross has been removed in favor of clicking/touching any part outside of the modal. - A method to create mockup clones of elements is now used to make placeholders. It is used for the input panel in the main area when it is moved to a sub-panel modal, this way there is not element disappearing. - Clicking on a keyword now shows a sub-messages panel with all messages with this keyword, in a similar way as for threads. Writing a messages there will add the keyword automatically. - Sub-messages panel will auto-scroll when messages are added. rel 458
author Goffi <goffi@goffi.org>
date Fri, 06 Jun 2025 11:08:05 +0200
parents 3a60bf3762ef
children
line wrap: on
line source

"""manage common dialogs"""

from typing import Callable, Literal
from browser import document, window, timer, console as log
from template import Template

log.warning = log.warn


class CancelError(Exception):
    """Dialog is cancelled"""

    def __init__(self, reason: str = "", text: str = "") -> None:
        self.reason = reason
        self.text = text
        super().__init__(text)


class Confirm:

    def __init__(self, message, ok_label="", cancel_label="", ok_color="success"):
        self._tpl = Template("dialogs/confirm.html")
        self.message = message
        self.ok_label = ok_label
        assert ok_color in ("success", "danger")
        self.ok_color = ok_color
        self.cancel_label = cancel_label
        self._notif_elt = None
        self.reset()

    def reset(self):
        """Reset values of callbacks and notif element"""
        self._ok_cb = None
        self._cancel_cb = None
        self._reject_cb = None
        if self._notif_elt is not None:
            self._notif_elt.remove()
            self._notif_elt = None

    def _default_cancel_cb(self, evt, notif_elt):
        notif_elt.remove()

    def cancel(self, reason: str = "", text: str = ""):
        """Cancel the dialog, without calling any callback

        will raise a CancelError
        """
        if self._notif_elt is None:
            log.warning("calling cancel on an unshown dialog")
        else:
            self._notif_elt.remove()
            self._notif_elt = None
            if self._reject_cb is not None:
                self._reject_cb(CancelError(reason, text))
            else:
                log.warning("no reject callback set")
        self.reset()

    def on_ok_click(self, evt):
        evt.preventDefault()
        evt.stopPropagation()
        assert self._ok_cb is not None
        self._ok_cb(evt, self._notif_elt)
        self.reset()

    def on_cancel_click(self, evt) -> None:
        evt.preventDefault()
        evt.stopPropagation()
        assert self._cancel_cb is not None
        self._cancel_cb(evt, self._notif_elt)
        self.reset()

    def show(self, ok_cb, cancel_cb=None, reject_cb=None):
        if cancel_cb is None:
            cancel_cb = self._default_cancel_cb
        self._ok_cb = ok_cb
        self._cancel_cb = cancel_cb
        self._reject_cb = reject_cb
        notif_elt = self._tpl.get_elt({
            "message": self.message,
            "ok_label": self.ok_label,
            "ok_color": self.ok_color,
            "cancel_label": self.cancel_label,
        })
        self._notif_elt = notif_elt

        document['notifs_area'] <= notif_elt
        timer.set_timeout(lambda: notif_elt.classList.add('state_appended'), 0)
        for ok_elt in notif_elt.select(".click_to_ok"):
            ok_elt.bind("click", self.on_ok_click)
        for ok_elt in notif_elt.select(".click_to_cancel"):
            ok_elt.bind("click", self.on_cancel_click)

    def _ashow_cb(self, evt, notif_elt, resolve_cb, confirmed):
        evt.stopPropagation()
        notif_elt.remove()
        resolve_cb(confirmed)

    async def ashow(self):
        return window.Promise.new(
            lambda resolve_cb, reject_cb:
            self.show(
                lambda evt, notif_elt: self._ashow_cb(evt, notif_elt, resolve_cb, True),
                lambda evt, notif_elt: self._ashow_cb(evt, notif_elt, resolve_cb, False),
                reject_cb
            )
        )


class Notification:

    def __init__(self):
        self._tpl = Template("dialogs/notification.html")

    def close(self, notif_elt):
        notif_elt.classList.remove('state_appended')
        notif_elt.bind("transitionend", lambda __: notif_elt.remove())

    def show(
        self,
        message: str,
        level: str = "info",
        delay: int = 5
    ) -> None:
        # we log in console error messages, may be useful
        if level in ("warning", "error"):
            print(f"[{level}] {message}")
        notif_elt = self._tpl.get_elt({
            "message": message,
            "level": level,
        })
        document["notifs_area"] <= notif_elt
        timer.set_timeout(lambda: notif_elt.classList.add('state_appended'), 0)
        timer.set_timeout(lambda: self.close(notif_elt), delay * 1000)
        for elt in notif_elt.select('.click_to_close'):
            elt.bind('click', lambda __: self.close(notif_elt))


class RetryNotification:
    def __init__(self, retry_cb):
        self._tpl = Template("dialogs/retry-notification.html")
        self.retry_cb = retry_cb
        self.counter = 0
        self.timer = None

    def retry(self, notif_elt):
        if self.timer is not None:
            timer.clear_interval(self.timer)
            self.timer = None
        notif_elt.classList.remove('state_appended')
        notif_elt.bind("transitionend", lambda __: notif_elt.remove())
        self.retry_cb()

    def update_counter(self, notif_elt):
        counter = notif_elt.select_one(".retry_counter")
        counter.text = str(self.counter)
        self.counter -= 1
        if self.counter < 0:
            self.retry(notif_elt)

    def show(
        self,
        message: str,
        level: str = "warning",
        delay: int = 5
    ) -> None:
        # we log in console error messages, may be useful
        if level == "error":
            log.error(message)
        elif level == "warning":
            log.warning(message)
        self.counter = delay
        notif_elt = self._tpl.get_elt({
            "message": message,
            "level": level,
        })
        self.update_counter(notif_elt)
        document["notifs_area"] <= notif_elt
        timer.set_timeout(lambda: notif_elt.classList.add('state_appended'), 0)
        self.timer = timer.set_interval(self.update_counter, 1000, notif_elt)
        for elt in notif_elt.select('.click_to_retry'):
            elt.bind('click', lambda __: self.retry(notif_elt))


class Modal:

    def __init__(
        self,
        content_elt,
        is_card: bool = False,
        closable: bool = False,
        close_cb: Callable|None = None,
        position: Literal["center", "left", "right", "top", "bottom"] = "center"
    ) -> None:
        self.position = position
        self.is_card = is_card
        if is_card and position == 'center':
            if not content_elt.classList.contains("modal-card"):
                raise ValueError(
                    'Element must have a "modal-card" class when `is_card` is used for center modal'
                )
        self.closable = closable
        self._close_cb = close_cb
        self._tpl = Template("dialogs/modal.html")
        self.content_elt = content_elt
        self._modal_elt = None
        self._closing = False
        self._is_panel = position != 'center'

    def _cleanup(self):
        if self._modal_elt is not None:
            self._modal_elt.remove()
            self._modal_elt = None
        self._closing = False

    def close(self):
        if self._modal_elt is None or self._closing:
            return

        self._closing = True

        self._modal_elt.classList.remove('is-active')

        if self._is_panel:
            self._modal_elt.classList.add('closing')

        def on_close_finished():
            if self._close_cb:
                self._close_cb()
            self._cleanup()

        timer.set_timeout(on_close_finished, 300)

    def on_background_click(self, evt):
        if self.closable and not self._closing:
            evt.preventDefault()
            evt.stopPropagation()
            self.close()

    def show(self) -> None:
        if self._modal_elt:
            self._cleanup()

        self._modal_elt = self._tpl.get_elt({
            "closable": self.closable,
            "position": self.position,
            "is_panel": self._is_panel
        })

        if self.position == 'center':
            container_elt = self._modal_elt.select_one(".modal-content")
            container_elt <= self.content_elt
        else:
            container_elt = self._modal_elt.select_one(".modal-panel-container")
            container_elt <= self.content_elt
            container_elt.classList.add(f"is-{self.position}")

        document['notifs_area'] <= self._modal_elt

        if self.closable:
            bg = self._modal_elt.select_one(".modal-background, .modal-panel-background")
            if bg:
                bg.bind("click", self.on_background_click)

        # Add active class after a small delay to trigger animation
        timer.set_timeout(lambda: self._modal_elt.classList.add('is-active'), 10)


notification = Notification()