Source code for qiskit_metal._gui.main_window_base

# -*- coding: utf-8 -*-

# This code is part of Qiskit.
#
# (C) Copyright IBM 2017, 2021.
#
# This code is licensed under the Apache License, Version 2.0. You may
# obtain a copy of this license in the LICENSE.txt file in the root directory
# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
#
# Any modifications or derivative works of this code must retain this
# copyright notice, and modified files need to carry a notice indicating
# that they have been altered from the originals.
"""GUI front-end interface for Qiskit Metal in PySide2."""
# pylint: disable=invalid-name

import logging
import os
import sys
from copy import deepcopy
from pathlib import Path

from PySide2 import QtCore, QtGui, QtWidgets
from PySide2.QtCore import QTimer
from PySide2.QtGui import QIcon
from PySide2.QtWidgets import QApplication, QFileDialog, QMainWindow, QMessageBox, QDockWidget

from .. import Dict, config
from ..toolbox_python._logging import setup_logger
from . import __version__
from .main_window_ui import Ui_MainWindow
from .utility._handle_qt_messages import slot_catch_error
from .widgets.log_widget.log_metal import LogHandler_for_QTextLog


class QMainWindowExtensionBase(QMainWindow):
    """This contains all the functions that the gui needs to call directly from
    the UI.

    Extends the `QMainWindow` class.
    """

    def __init__(self):
        """"""
        super().__init__()
        # Set manually
        self.handler = None  # type: QMainWindowBaseHandler
        self.force_close = False

    @property
    def logger(self) -> logging.Logger:
        """Get the logger."""
        return self.handler.logger

    @property
    def settings(self) -> QtCore.QSettings:
        """Get the settings."""
        return self.handler.settings

    @property
    def gui(self) -> 'QMainWindowBaseHandler':
        """Get the GUI."""
        self.handler

    def _remove_log_handlers(self):
        """Remove the log handlers."""
        if hasattr(self, 'log_text'):
            self.log_text.remove_handlers(self.logger)

    def destroy(self,
                destroyWindow: bool = True,
                destroySubWindows: bool = True):
        """When the window is cleaned up from memory.

        Args:
            destroyWindow (bool): Whether or not to destroy the window.  Defaults to True.
            destroySubWindows (bool): Whether or not to destroy sub windows  Defaults to True.
        """
        self._remove_log_handlers()
        super().destroy(destroyWindow=destroyWindow,
                        destroySubWindows=destroySubWindows)

    def save_window_settings(self):
        """Save the window settings."""
        self.logger.info('Saving window state')
        # get the current size and position of the window as a byte array.
        self.settings.setValue('metal_version', __version__)
        self.settings.setValue('geometry', self.saveGeometry())
        self.settings.setValue('windowState', self.saveState())
        self.settings.setValue('stylesheet', self.handler._stylesheet)

    def restore_window_settings(self):
        """Call a Qt built-in function to restore values from the settings
        file.

        Raises:
            Exception: Error in restoration
        """

        version_settings = self.settings.value('metal_version',
                                               defaultValue='0')
        if __version__ > version_settings:
            self.logger.debug(
                f"Clearing window settings [{version_settings}]...")
            self.settings.clear()

        try:
            self.logger.debug("Restoring window settings...")

            # should probably call .encode("ascii") here
            geom = self.settings.value('geometry', '')
            if isinstance(geom, str):
                geom = geom.encode("ascii")
            self.restoreGeometry(geom)

            window_state = self.settings.value('windowState', '')
            if isinstance(window_state, str):
                window_state = window_state.encode("ascii")
            self.restoreState(window_state)

            self.handler.load_stylesheet(
                self.settings.value('stylesheet',
                                    self.handler._stylesheet_default))

            # TODO: Recent files
        except Exception as e:
            self.logger.error(f'ERROR [restore_window_settings]: {e}')

    def bring_to_top(self):
        """Bring window to top.

        Note that on Windows, this doesn't always work quite well.
        """
        self.raise_()
        self.activateWindow()

    def get_screenshot(self,
                       name='shot',
                       type_='png',
                       display=True,
                       disp_ops=None):
        """Grad a screenshot of the main window, save to file, copy to
        clipboard and visualize in jupyter.

        Args:
            name (string): File name without extension
            type_ (string): File format and name extension
            display (bool): Indicates whether to visualize or not in jupyter notebook
            disp_ops (dict): Used to pass options to IPython.display.Image (example: width)
        """

        path = Path(name + '.' + type_).resolve()

        # grab the main window
        screenshot = self.grab()  # type: QtGui.QPixMap
        screenshot.save(str(path), type_)

        # copy to clipboard
        QtWidgets.QApplication.clipboard().setPixmap(screenshot)
        self.logger.info(
            f'Screenshot copied to clipboard and saved to:\n {path}')

        # visualize in jupyter (adapt resolution and width first)
        if display:
            from IPython.display import Image, display
            _disp_ops = dict(width=500)
            _disp_ops.update(disp_ops or {})
            width_to_scale = round(
                min(_disp_ops['width'] * 1.5, screenshot.width()))
            if not width_to_scale == screenshot.width():
                path = Path(name + str(width_to_scale) + '.' + type_).resolve()
                screenshot = screenshot.scaledToWidth(
                    width_to_scale, mode=QtCore.Qt.SmoothTransformation)
                screenshot.save(str(path), type_)
            display(Image(filename=str(path), **_disp_ops))

    def toggle_all_docks(self, do_hide: bool = None):
        """Show or hide all docks.

        Args:
            do_hide (bool): Hide or show.  Defaults to None -- toggle.
        """
        # Get all docks to show/hide. Ignore edit source
        docks = [
            widget for widget in self.children()
            if isinstance(widget, QDockWidget)
        ]
        docks = list(
            filter(
                lambda x: not x.windowTitle().lower().startswith('edit source'),
                docks))
        docks += [
            widget for widget in self.gui.plot_win.children()
            if isinstance(widget, QDockWidget)
        ]  # specific

        if do_hide is None:
            dock_states = {dock: dock.isVisible() for dock in docks}
            do_hide = any(
                dock_states.values())  # if any are visible then hide all

        for dock in docks:
            if do_hide:
                dock.hide()
            else:
                dock.show()

        # TODO: small -- fix, changes which dock is on top or now

    ##################################################################
    # For actions

    @slot_catch_error()
    def _screenshot(self, _=None):
        """Used to call from action."""
        self.get_screenshot()

    @slot_catch_error()
    def load_stylesheet_default(self, _=None):
        """Used to call from action."""
        self.handler.load_stylesheet('default')

    @slot_catch_error()
    def load_stylesheet_metal_dark(self, _=None):
        """Used to call from action."""
        self.handler.load_stylesheet('metal_dark')

    @slot_catch_error()
    def load_stylesheet_dark(self, _=None):
        """Used to call from action."""
        self.handler.load_stylesheet('qdarkstyle')

    @slot_catch_error()
    def load_stylesheet_open(self, _=None):
        """Used to call from action."""
        default_path = str(self.gui.path_stylesheets)
        filename = QFileDialog.getOpenFileName(
            self, 'Select Qt stylesheet file `.qss`', default_path)[0]
        if filename:
            self.logger.info(f'Attempting to load stylesheet file {filename}')

            self.handler.load_stylesheet(filename)


class QMainWindowBaseHandler():
    """Abstract Class to wrap and handle main window (QMainWindow).

    Assumes a UI that has:
        * log_text: a QText for logging

    Assumes we have functions:
        * setup_logger

    Assumes we have objects:
         * config.log.format
         * config.log.datefmt
         * config._ipython
    """
    _myappid = 'QiskitMetal'
    _img_logo_name = 'my_logo.png'
    _img_folder_name = '_imgs'
    _dock_names = []
    _QMainWindowClass = QMainWindowExtensionBase
    __gui_num__ = -1  # used to count the number of gui instances
    _stylesheet_default = 'default'

    @staticmethod
    def __UI__() -> QMainWindow:  # pylint: disable=invalid-name
        """Abstract, replace with UI class."""
        return None

    def __init__(self, logger: logging.Logger = None, handler=False):
        """Can pass in logger.

        Args:
            logger (logging.Logger): The logger.  Defaults to None.
            handler (bool): Not used.  Defaults to False.

        Attributes:
            _settings: Used to save the state of the window
                This information is often stored in the system
                registry on Windows, and in property list files on macOS and iOS.
        """
        self.__class__.__gui_num__ += 1  # used to give a unique identifier

        self.config = deepcopy(config.GUI_CONFIG)
        self.settings = QtCore.QSettings(self._myappid, 'MainWindow')

        # Logger
        if not logger:
            # print('Setting up logger')
            logger = setup_logger(
                # so that they are not all the same
                f'gui{self.__class__.__gui_num__ }',
                config.log.format,
                config.log.datefmt,
                force_set=True,
                create_stream=self.config.stream_to_std,
            )
            log_level = int(
                getattr(logging, self.config.logger.get('level', 'DEBUG')))
            logger.setLevel(log_level)

        self.logger = logger
        self._log_handler = None  # defined in self._setup_logger
        self._stylesheet = self._stylesheet_default  # set by load_stylesheet

        # File paths
        self.path_gui = self._get_file_path()  # Path to gui folder
        self.path_imgs = Path(self.path_gui) / \
            self._img_folder_name  # Path to gui imgs folder
        if not self.path_imgs.is_dir():
            text = f'Bad File path for images! {self.path_imgs}'
            print(text)
            self.logger.error(text)

        # Main Window and App level
        self.main_window = self._QMainWindowClass()
        self.main_window.handler = self
        self.qApp = self._setup_qApp()
        self._setup_main_window_tray()

        # Style and window
        self._style_sheet_path = None
        self.style_window()

        # UI
        self.ui = self.__UI__()
        self.ui.setupUi(self.main_window)
        self.main_window.ui = self.ui

        self.ui.log_text.dock_window = self.ui.dockLog
        self._setup_logger()
        self._setup_window_size()

        self._ui_adjustments()  # overwrite

        self.main_window.restore_window_settings()

    @property
    def path_stylesheets(self):
        """Returns the path to the stylesheet."""
        return Path(self.path_gui) / 'styles'

    def style_window(self):
        """Styles the window."""
        # fusion macintosh # windows
        self.main_window.setStyle(QtWidgets.QStyleFactory.create("fusion"))
        # TODO; add stlyesheet to load here - maybe pull form settings

    def _ui_adjustments(self):
        """Any touchups to the loaded ui that need be done soon."""
        pass

    def _get_file_path(self):
        """Get the dir name of the current path in which this file is
        stored."""
        return os.path.dirname(__file__)

    def _setup_qApp(self):
        """Only one qApp can exist at a time, so check before creating one.

        Returns:
            QApplication: a setup QApplication

        There are three classes:
            * QCoreApplication - base class. Used in command line applications.
            * QGuiApplication - base class + GUI capabilities. Used in QML applications.
            * QApplication - base class + GUI + support for widgets. Use it in QtWidgets applications.

        See:
            * https://forum.qt.io/topic/94834/differences-between-qapplication-qguiappication-qcoreapplication-classes/3
            * https://github.com/matplotlib/matplotlib/blob/9984f9c4db7bfb02ffca53b7823acb8f8e223f6a/lib/matplotlib/backends/backend_qt5.py#L98
        """

        self.qApp = QApplication.instance()

        if self.qApp is None:
            self.logger.warning("QApplication.instance is None.")

            # Kickstart()

            # Did it work now
            self.qApp = QApplication.instance()

            if self.qApp is None:

                self.logger.error(r"""ERROR: QApplication.instance is None.
                Did you run a cell with the magic in IPython?
                ```python
                    %gui qt
                ```
                This command allows IPython to integrate itself with the Qt event loop,
                so you can use both a GUI and an interactive prompt together.
                Reference: https://ipython.readthedocs.io/en/stable/config/eventloops.html
                """)

        # for window platofrms only
        # QApplication.platformName() -- on mac: 'cocoa'
        if os.name.startswith('nt'):
            # Arbitrary string, needed for icon in taskbar to be custom set proper
            # https://stackoverflow.com/questions/1551605/how-to-set-applications-taskbar-icon-in-windows-7
            import ctypes
            ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
                self._myappid)

        app = self.qApp
        if app:
            app.setOrganizationName(r"IBM Quantum Metal")
            app.setOrganizationDomain(r"https://www.ibm.com/quantum-computing")
            app.setApplicationName(r"Qiskit Metal")

        return self.qApp

    def _setup_main_window_tray(self):
        """Sets up the main window tray."""

        if self.path_imgs.is_dir():
            icon = QIcon(str(self.path_imgs / self._img_logo_name))
            self.main_window.setWindowIcon(icon)

            # not sure if this works, but let's try
            self._icon_tray = QtWidgets.QSystemTrayIcon(icon, self.main_window)
            self._icon_tray.show()
            self.qApp.setWindowIcon(icon)

    def create_log_handler(self, name_toshow: str, logger: logging.Logger):
        """Creates a log handler.

        Args:
            name_toshow (str): Display name
            logger (logging.Logger): The logger

        Returns:
            LogHandler_for_QTextLog: A LogHandler_for_QTextLog
        """
        return LogHandler_for_QTextLog(name_toshow, self, self.ui.log_text,
                                       logger)

    @slot_catch_error()
    def _setup_logger(self):
        """Setup logging UI.

        Show wellcome message.
        """
        if hasattr(self.ui, 'log_text'):

            self.ui.log_text.img_path = self.path_imgs

            self._log_handler = self.create_log_handler('GUI', self.logger)

            QTimer.singleShot(1500, self.ui.log_text.welcome_message)

        else:
            self.logger.warning('UI does not have `log_text`')

    def _setup_window_size(self):
        """Setup the window size."""
        if self.config.main_window.auto_size:
            screen = self.qApp.primaryScreen()
            # screen.name()

            rect = screen.availableGeometry()
            rect.setWidth(rect.width() * 0.9)
            rect.setHeight(rect.height() * 0.9)
            rect.setLeft(rect.left() + rect.width() * 0.1)
            rect.setTop(rect.top() + rect.height() * 0.1)
            self.main_window.setGeometry(rect)

    def load_stylesheet(self, path=None):
        """Load and set stylesheet for the main gui.

        Args:
            path (str) : Path to stylesheet or its name.
                Can be: 'default', 'qdarkstyle' or None.
                `qdarkstyle` requires
                >>> pip install qdarkstyle

        Returns:
            bool: False if failure, otherwise nothing

        Raises:
            ImportError: Import failure
        """
        result = True
        if path == 'default' or path is None:
            self._style_sheet_path = 'default'
            self.main_window.setStyleSheet('default')

        elif path == 'qdarkstyle':
            try:
                import qdarkstyle
            except ImportError:
                QMessageBox.warning(
                    self.main_window, 'Failed.',
                    'Error, you did not seem to have installed qdarkstyle.\n'
                    'Please do so from the terminal using\n'
                    ' >>> pip install qdarkstyle')

            os.environ['QT_API'] = 'pyside2'
            self.main_window.setStyleSheet(qdarkstyle.load_stylesheet())

        elif path == 'metal_dark':
            path_full = self.path_stylesheets / 'metal_dark' / 'style.qss'
            # print(f'path_full = {path_full}')
            self._load_stylesheet_from_file(path_full)

        else:
            self._load_stylesheet_from_file(path)

        if result:  # Set successfuly
            self._stylesheet = path
            self.settings.setValue('stylesheet', self._stylesheet)
        else:  # Failed to set
            return False

    def _load_stylesheet_from_file(self, path: str):
        """Load the sylesheet from a file.

        Args:
            path (str): Path to file

        Returns:
            bool: False if failure, otherwise nothing

        Raises:
            Exception: Stylesheet load failure
        """
        # print(f'path = {path}')
        try:
            path = Path(str(path))
            if path.is_file():
                self._style_sheet_path = str(path)
                stylesheet = path.read_text()
                stylesheet = stylesheet.replace(':/metal-styles',
                                                str(self.path_stylesheets))

                # if windows, double the slashes in the paths
                if os.name.startswith('nt'):
                    stylesheet = stylesheet.replace("\\", "\\\\")

                self.main_window.setStyleSheet(stylesheet)
                return True

            else:
                self.logger.error(
                    'Could not find the stylesheet file where expected %s',
                    path)
                return False
        except Exception as e:
            self.logger.error(f'_load_stylesheet_from_file error: {e}')

    def screenshot(self, name='shot', type_='png', display=True, disp_ops=None):
        """Alias for get_screenshot()."""
        self.main_window.get_screenshot(name, type_, display, disp_ops)

    def save_file(self):
        """Save file. Called on exit.

        Raises:
            NotImplementedError: Function not written
        """
        raise NotImplementedError()

    def show(self):
        """Show the main window."""
        self.main_window.show()

    def clear_settings(self):
        """Clear the settings that get saved each time the main window is
        closed.

        This will reset the window layout to the default.
        """
        self.settings.clear()

    def set_font_size(self, font_size: int):
        """Set font size of the applicaiton globally in points.

        Args:
            font_size (int): New font size
        """
        app = self.qApp
        # TODO: DO not just overwrite, but append or update style sheet.
        # Maybe allow user to edit as a whole in a widget
        app.setStyleSheet("QWidget{font-size:" + f"{font_size}" + "pt;}")


def kick_start_qApp():
    """Kick start the application.

    Returns:
        QtCore.QCoreApplication: the application

    Raises:
        AttributeError: Attribute only exists for Qt >= 5.6
        Exception: Magic method failure
    """
    qApp = QtCore.QCoreApplication.instance()

    if qApp is None:
        try:
            # TODO: See
            QtWidgets.QApplication.setAttribute(
                QtCore.Qt.AA_EnableHighDpiScaling)
        except AttributeError:  # Attribute only exists for Qt >= 5.6
            pass

        qApp = QApplication(sys.argv)

        if qApp is None:
            logging.error("QApplication.instance is None.")

            if config._ipython:
                # iPython has magic for loop
                logging.error(
                    "QApplication.instance: Attempt magic IPython %%gui qt5")
                try:
                    from IPython import get_ipython
                    ipython = get_ipython()
                    ipython.magic('gui qt5')

                except Exception as e:
                    print("exception")
                    logging.error(f"FAILED: {e}")
                    print(e)

            else:
                # We are not running form IPython, manually boot
                logging.error(
                    "QApplication.instance: Attempt to manually create qt5 QApplication"
                )
                qApp = QtWidgets.QApplication(["qiskit-metal"])
                qApp.lastWindowClosed.connect(qApp.quit)

    return qApp