HEX
Server: Apache/2.4.46 (Win64) OpenSSL/1.1.1j PHP/8.4.25
System: Windows NT DESKTOP-4TAV2RJ 10.0 build 19045 (Windows 10) AMD64
User: fred (0)
PHP: 8.4.25
Disabled: NONE
Upload Files
File: C:/Users/fred/anaconda3/Lib/site-packages/anaconda_navigator/widgets/web/whats_new.py
# -*- coding: utf8 -*-

"""Implementation of a "What's new" dialog."""

from __future__ import annotations

__all__ = ['Update', 'WhatsNewDialog']

import collections.abc
import functools
import io
import typing

import attrs

from qtpy import QtCore
from qtpy import QtWebEngineWidgets
from qtpy import QtWidgets

from anaconda_navigator.static import content as static_content
from anaconda_navigator.config import CONF
from anaconda_navigator import widgets
from anaconda_navigator.widgets import dialogs
from . import common

if typing.TYPE_CHECKING:
    import typing_extensions


def mode() -> typing.Literal['light', 'dark']:
    """Detect the current mode."""
    if CONF.get('main', 'dark_mode'):
        return 'dark'
    return 'light'


def prepare_url(url: str) -> QtCore.QUrl:
    """Prepare URL with injected details on light/dark mode."""
    result: QtCore.QUrl = QtCore.QUrl(url)
    query: QtCore.QUrlQuery = QtCore.QUrlQuery(result.query())
    query.addQueryItem('theme', mode())
    result.setQuery(query)
    return result


class IndexHelper:
    """
    Helper class to manage indexes of visible navigation dots.

    This is needed for cases when we can show fewer dots that we actually need to (e.g. we only have space for 7 dots,
    but we have 11 pages). Works for valid cases as well (i.e. we have enough space for all dots)

    It basically replaces all excessive dots in the middle with a visible dot in the center. This dot is selected when
    any page from the middle is active, and activates the first page of the "middle group" when clicked.

    :param total: The total number of dots that ideally should be rendered.
    :param allowed: The maximum number of dots we can actually draw.
    """

    __slots__ = ('delta', 'left', 'right', 'total')

    def __init__(self, total: int, allowed: int) -> None:
        """Initialize new instance of a :class:`~IndexHelper`."""
        allowed = min(allowed, total)

        self.delta: typing.Final[int] = allowed - total
        self.left: typing.Final[int] = allowed - (allowed // 2) - 1
        self.right: typing.Final[int] = total - (allowed // 2)
        self.total: typing.Final[int] = total

    def __call__(self, index: int) -> int:
        """Retrieve index of a visible dot when :code:`index` page is selected."""
        if index <= self.left:
            return index
        if index < self.right:
            return self.left
        return index + self.delta

    def __iter__(self) -> collections.abc.Iterator[int]:
        """Iterate through page indexes each visible dot represents."""
        yield from range(self.left + 1)
        yield from range(self.right, self.total)


class Update(typing.TypedDict):
    """
    Description of a single update to show.

    .. note::

        Should be retrieved from the server.
    """

    id: str
    title: str
    url: str


@attrs.frozen
class UpdatePage:  # pylint: disable=too-few-public-methods
    """Container for all details related to a single page with updates."""

    info: Update
    page: common.ExtendedWebEnginePage = attrs.field(
        factory=functools.partial(common.ExtendedWebEnginePage, origin='whats-new'),
        init=False,
    )

    def load(self) -> None:
        """Request to load a web page."""
        self.page.load(prepare_url(self.info['url']))


@attrs.frozen
class UpdatePageWithContent(UpdatePage):
    """Custom :class:`~UpdatePage` with a predefined web-page content."""

    content: bytes

    @classmethod
    def from_file(
            cls,
            path: str,
            *,
            title: str = 'What\'s new',
            url: str = 'navigator://content',
            identity: str = ':content:',
    ) -> typing_extensions.Self:
        """Load a web-page from a file."""
        stream: io.BufferedReader
        with open(path, 'rb') as stream:
            content: bytes = stream.read()
        return cls(content=content, info={'id': identity, 'title': title, 'url': url})

    def load(self) -> None:
        """Request to load a web page."""
        self.page.setContent(self.content, 'text/html', prepare_url(self.info['url']))


class WhatsNewDialog(dialogs.DialogBase):  # pylint: disable=too-many-instance-attributes
    """
    Dialog for a "what's new" content.

    :param content: Updates to show in the dialog. If none provided - will fall back to the "whats_new_empty" page.
    """

    sig_navigator_link_requested = QtCore.Signal(object)

    def __init__(self, parent: QtWidgets.QWidget | None = None, content: collections.abc.Iterable[Update] = ()) -> None:
        """Initialize new instance of a :class:`~WhatsNewDialog`."""
        super().__init__(parent)

        pages: list[UpdatePage] = list(map(UpdatePage, content))
        if not pages:
            pages.append(UpdatePageWithContent.from_file(static_content.WHATS_NEW_EMPTY_PAGE))

        self.__pages: typing.Final[tuple[UpdatePage, ...]] = tuple(pages)
        self.__page_index: int = -1
        self.__page_index_helper: typing.Final[IndexHelper] = IndexHelper(len(self.__pages), 7)  # max dots

        self.__pending: bool = False
        self.__ready: bool = False

        # interface

        self.__web_view: typing.Final[QtWebEngineWidgets.QWebEngineView] = QtWebEngineWidgets.QWebEngineView()

        self.__previous_button: typing.Final[QtWidgets.QPushButton] = widgets.ButtonNormal()
        self.__previous_button.setCursor(QtCore.Qt.PointingHandCursor)
        self.__previous_button.setText('< prev')
        self.__previous_button.clicked.connect(lambda: self.__set_page(self.page_index - 1))

        self.__navigation_dots: typing.Final[tuple[widgets.DotControlButton, ...]] = tuple(
            map(self.__create_dot, self.__page_index_helper),
        )

        self.__next_button: typing.Final[QtWidgets.QPushButton] = widgets.ButtonNormal()
        self.__next_button.setCursor(QtCore.Qt.PointingHandCursor)
        self.__next_button.setText('next >')
        self.__next_button.clicked.connect(lambda: self.__set_page(self.page_index + 1))

        control_layout: typing.Final[QtWidgets.QHBoxLayout] = QtWidgets.QHBoxLayout()
        control_layout.setContentsMargins(0, 0, 0, 0)
        control_layout.setSpacing(24)
        control_layout.addWidget(self.__previous_button)
        control_layout.addStretch(1)
        for navigation_dot in self.__navigation_dots:
            control_layout.addWidget(navigation_dot)
        control_layout.addStretch(1)
        control_layout.addWidget(self.__next_button)

        main_layout: typing.Final[QtWidgets.QVBoxLayout] = QtWidgets.QVBoxLayout()
        main_layout.setContentsMargins(0, 0, 0, 0)
        main_layout.setSpacing(0)
        main_layout.addWidget(self.__web_view, 1)
        main_layout.addSpacing(16)
        main_layout.addLayout(control_layout)

        self.label_title_bar.setText('What\'s new')
        self.setFixedSize(600, 800)
        self.setLayout(main_layout)

        # initialize content
        pages[0].page.loadFinished.connect(self.__set_ready)

        page: UpdatePage
        for page in self.__pages:
            page.page.sig_navigator_link_requested.connect(self.sig_navigator_link_requested)
            page.load()

        self.page_index = 0

    @property
    def page_index(self) -> int:  # noqa: D401
        """Index of a current page displayed."""
        return self.__page_index

    @page_index.setter
    def page_index(self, value: int) -> None:
        """Switch to a specific update page."""
        value %= len(self.__pages)

        # update dots
        self.__navigation_dots[self.__page_index_helper(self.__page_index)].setChecked(False)
        self.__navigation_dots[self.__page_index_helper(value)].setChecked(True)

        # skip further processing if page did not change
        if value == self.__page_index:
            return

        # show selected page
        page: UpdatePage = self.__pages[value]
        self.label_title_bar.setText(page.info['title'])
        self.__web_view.setPage(page.page)

        # update buttons
        self.__previous_button.setEnabled(value > 0)
        self.__next_button.setEnabled(value + 1 < len(self.__pages))

        # mark current page as seen
        seen: set[str] = set(CONF.get('internal', 'whats_new_seen'))
        seen.add(page.info['id'])
        CONF.set('internal', 'whats_new_seen', sorted(seen))

        # remember current value
        self.__page_index = value

    def show(self) -> None:
        """Show this dialog to the user."""
        if self.__ready:
            super().show()
        else:
            self.__pending = True

    def __create_dot(self, index: int) -> widgets.DotControlButton:
        """Create a new navigation dot."""
        result: widgets.DotControlButton = widgets.DotControlButton()
        result.clicked.connect(lambda: self.__set_page(index))
        return result

    def __set_page(self, index: int) -> None:
        """
        Switch to an update page.

        .. note::

            Used for lambdas connected to signals.
        """
        self.page_index = index

    def __set_ready(self) -> None:
        """
        Mark a widget as ready to be displayed.

        .. note::

            Should be called after at least a first update page is loaded.
        """
        self.__ready = True
        if self.__pending:
            self.__pending = False
            self.show()