text: ###################################################################################################
  #                              MIT Licence (C) 2022 Cubicpath@Github                              #
  ###################################################################################################
  """Module containing miscellaneous :py:class:`QWidget` Widgets."""
  
  __all__ = (
      'ExceptionReporter',
      'ExceptionLogger',
      'ExternalTextBrowser',
      'HistoryComboBox',
      'LicenseViewer',
      'PasteLineEdit',
      'ReadmeViewer',
  )
  
  import traceback
  import webbrowser
  from collections import defaultdict
  from collections import namedtuple
  from collections.abc import Callable
  from collections.abc import Sequence
  from importlib.metadata import metadata
  from types import TracebackType
  from typing import Any
  from typing import Final
  from typing import Optional
  
  from PySide6.QtCore import *
  from PySide6.QtGui import *
  from PySide6.QtWidgets import *
  
  from .._version import __version__
  from ..constants import *
  from ..events import EventBus
  from ..exceptions import ExceptionEvent
  from ..models import DeferredCallable
  from ..models import DistributedCallable
  from ..network import encode_url_params
  from ..utils import current_requirement_licenses
  from .app import app
  from .utils import delete_layout_widgets
  from .utils import init_objects
  from .utils import PARENT_PACKAGE
  from .utils import scroll_to_top
  
  
  class ExceptionReporter(QWidget):
      """A :py:class:`QWidget` that displays logged exceptions and their traceback."""
  
      def __init__(self, logger: 'ExceptionLogger') -> None:
          super().__init__()
          EventBus['exceptions'].subscribe(DeferredCallable(self.reload_exceptions), ExceptionEvent)
  
          self.selected: int = 0
          self.logger: ExceptionLogger = logger
          self.setWindowTitle(app().translator('gui.exception_reporter.title'))
          self.setWindowIcon(self.logger.icon())
          self.resize(QSize(750, 400))
          self._init_ui()
  
      def _init_ui(self) -> None:
          layout = QHBoxLayout(self)
          self.left_panel = QFrame(self)
          self.right_panel = QFrame(self)
  
          left_label = QLabel(self.left_panel)
          right_label = QLabel(self.right_panel)
          clear_all_button = QPushButton(self.left_panel)
          self.trace_back_viewer = ExternalTextBrowser(self)
          self.clear_button = QPushButton(self.right_panel)
          self.report_button = QPushButton(self.right_panel)
  
          # Define the scroll area/widget for the left panel
          self.scroll_area = QScrollArea(self)
          self.scroll_widget = QWidget(self.scroll_area)
          self.scroll_widget.setLayout(QVBoxLayout())
          self.scroll_widget.layout().setAlignment(Qt.AlignTop)
          self.scroll_area.setWidget(self.scroll_widget)
  
          init_objects({
              self.scroll_area: {
                  'widgetResizable': True
              },
              clear_all_button: {
                  'clicked': self.clear_all_exceptions
              },
              self.trace_back_viewer: {
                  'disabled': True,
                  'font': QFont('consolas', 10),
                  'lineWrapMode': QTextEdit.NoWrap
              },
              self.clear_button: {
                  'disabled': True,
                  'size': {'maximum': (self.report_button.width() // 1, None)},
                  'clicked': self.clear_current_exception
              },
              self.report_button: {
                  'disabled': True,
                  'clicked': self.report_current_exception
              }
          })
  
          app().init_translations({
              self.setWindowTitle: 'gui.exception_reporter.title',
  
              # Labels
              left_label.setText: 'gui.exception_reporter.exception_list',
              right_label.setText: 'gui.exception_reporter.traceback_label',
  
              # Buttons
              clear_all_button.setText: 'gui.exception_reporter.clear_all',
              self.clear_button.setText: 'gui.exception_reporter.clear',
              self.report_button.setText: 'gui.exception_reporter.report_clear'
          })
  
          layout.addWidget(self.left_panel)
          layout.addWidget(self.right_panel)
          self.left_panel.setLayout(QVBoxLayout())
          self.right_panel.setLayout(QVBoxLayout())
          buttons = QHBoxLayout()
  
          self.left_panel.layout().addWidget(left_label)
          self.left_panel.layout().addWidget(self.scroll_area)
          self.left_panel.layout().addWidget(clear_all_button)
  
          self.right_panel.layout().addWidget(right_label)
          self.right_panel.layout().addWidget(self.trace_back_viewer)
          self.right_panel.layout().addLayout(buttons)
          buttons.addWidget(self.report_button)
          buttons.addWidget(self.clear_button)
  
      def report_current_exception(self) -> None:
          """Report the current exception to the exception logger."""
          base: str = 'https://github.com/Cubicpath/HaloInfiniteGetter/issues/new'
          params = {
              'template': 'bug-report.yaml',
              'assignees': 'Cubicpath',
              'labels': 'bug',
              'projects': 'Cubicpath/HaloInfiniteGetter/2',
              'title': f'[Bug]: '  # Standard bug report title prefix
                       f'({self.logger.exception_log[self.selected][1].__class__.__name__}) '  # Exception Type
                       f'{str(self.logger.exception_log[self.selected][1]).rstrip(".")}',      # Exception Message
              'version': f'v{__version__}',
              'logs': traceback.format_tb(self.logger.exception_log[self.selected][2])[0].strip()
          }
          webbrowser.open(f'{base}?{encode_url_params(params)}')
  
      def disable_exception_widgets(self) -> None:
          """Disables the widgets on the right pane."""
          self.trace_back_viewer.clear()
          self.trace_back_viewer.setDisabled(True)
          self.report_button.setDisabled(True)
          self.clear_button.setDisabled(True)
  
      def clear_all_exceptions(self) -> None:
          """Clear all exceptions from the list."""
          self.logger.clear_exceptions()
          self.disable_exception_widgets()
          delete_layout_widgets(self.scroll_widget.layout())
  
      def clear_current_exception(self) -> None:
          """Clears the currently selected exception and removes it from the log."""
          if (item := self.scroll_widget.layout().takeAt(self.selected)) is not None:
              self.logger.remove_exception(self.selected)
              item.widget().deleteLater()
          self.reload_exceptions()
          self.disable_exception_widgets()
  
      def reload_exceptions(self) -> None:
          """Load the exceptions from the logger."""
          delete_layout_widgets(self.scroll_widget.layout())
          for i, error in enumerate(self.logger.exception_log):
              button = QPushButton(f'{type(error.exception).__name__}: {error.exception}', self.scroll_widget)
              button.clicked.connect(DeferredCallable(setattr, self, 'selected', i))
              button.clicked.connect(DistributedCallable((
                  self.clear_button.setDisabled,
                  self.report_button.setDisabled,
                  self.trace_back_viewer.setDisabled
              ), False))
              button.clicked.connect(DeferredCallable(self.trace_back_viewer.setText, DeferredCallable(
                  app().translator, 'gui.exception_reporter.traceback_view',
                  type(error.exception).__name__, error[1], traceback.format_tb(error[2])[0]
              )))
  
              button.setStyleSheet("text-align:left;")
              button.setMaximumWidth((self.size().width() // 3) - 50)
  
              self.scroll_widget.layout().addWidget(button)
  
      def resizeEvent(self, event: QResizeEvent) -> None:
          """Resizes the left panel to better fit the window."""
          self.left_panel.setMaximumWidth(event.size().width() // 3)
          for i in range(self.scroll_widget.layout().count()):
              self.scroll_widget.layout().itemAt(i).widget().setMaximumWidth((event.size().width() // 3) - 50)
  
  
  class ExceptionLogger(QPushButton):
      """A :py:class:`QPushButton` that logs exceptions to the event bus."""
      # TODO: Add date to the exception log
      LoggedException: tuple[int, Exception, TracebackType] = namedtuple('LoggedException', ['severity', 'exception', 'traceback'], defaults=[None])
      """A named tuple that contains the severity of the exception, the exception itself, and an optional traceback."""
  
      level_icon_list: list = [
          QStyle.SP_MessageBoxInformation,  # 0, Not a concern
          QStyle.SP_MessageBoxWarning,      # 1, Warning
          QStyle.SP_MessageBoxCritical      # 2, Error
      ]
  
      def __init__(self, *args, **kwargs) -> None:
          """Initialize the ExceptionLogger."""
          super().__init__(*args, **kwargs)
          EventBus['exceptions'].subscribe(self.on_exception, ExceptionEvent)
  
          self.exception_log:    list[ExceptionLogger.LoggedException] = []
          self.reporter:         ExceptionReporter = ExceptionReporter(self)
          self.severity:         int = 0
  
          self.reporter.setMinimumWidth(300)
  
      def clear_exceptions(self) -> None:
          """Clear the exception log and disable the button."""
          self.exception_log.clear()
          self.severity = 0
          self.setText('')
  
      def remove_exception(self, index: int) -> None:
          """Remove an exception from the log and update the current severity."""
          if self.exception_log:
              self.exception_log.pop(index)
  
          if len(self.exception_log) == 0:
              self.clear_exceptions()
          else:
              logged = len(self.exception_log)
              self.setText(f'({logged})' if logged < 10 else '(9+)')
              self.severity = self.exception_log[0].severity
  
      def on_exception(self, event: ExceptionEvent) -> None:
          """Update the exception log and change set the max level."""
          if isinstance(event.exception, Warning) and self.severity < 1:
              level = 1
          else:
              level = 2
          if self.severity < level:
              self.severity = level
  
          self.exception_log.append(ExceptionLogger.LoggedException(level, event.exception, event.traceback))
          self.sort_exceptions()
  
          logged = len(self.exception_log)
          self.setText(f'({logged})' if logged < 10 else '(9+)')
  
      def sort_exceptions(self) -> None:
          """Sort the exception log by severity."""
          if self.exception_log:
              self.exception_log = list(reversed(sorted(self.exception_log, key=lambda x: x.severity)))
              self.severity = self.exception_log[0].severity
          else:
              self.severity = 0
  
      @property
      def severity(self) -> int:
          """Get the max level of the exception log."""
          return self._severity
  
      @severity.setter
      def severity(self, value: int) -> None:
          """Set the max level of the exception log and update the icon."""
          self._severity = value
          self.setIcon(self.style().standardIcon(self.level_icon_list[self.severity]))
          self.reporter.setWindowIcon(self.icon())
  
  
  class ExternalTextBrowser(QTextBrowser):
      """:py:class:`QTextBrowser` with ability to map keys to :py:class:`Callable`'s.
  
      Also supports external image loading and caching.
      """
      remote_image_cache: dict[str, bytes] = {}
  
      def __init__(self, *args, **kwargs) -> None:
          super().__init__(*args, **kwargs)
  
          # default factory producing empty DeferredCallables
          self.key_callable_map: defaultdict[int, Callable] = defaultdict(DeferredCallable)
          self.cached_text: str = ''
          self.cached_type: str = ''
  
      def hot_reload(self) -> None:
          """Reload cached text using the cached type's function."""
          # Remember scroll position
          scroll = self.verticalScrollBar().sliderPosition()
  
          # Reload all resources and replace text with cached text
          self.clear()
          match self.cached_type:
              case 'markdown':
                  self.setMarkdown(self.cached_text)
              case 'html':
                  self.setHtml(self.cached_text)
              case 'text':
                  self.setPlainText(self.cached_text)
          self.verticalScrollBar().setSliderPosition(scroll)
  
      def set_hot_reloadable_text(self, text: str, text_type: str) -> None:
          """Set text that is designated to be hot-reloadable."""
          self.cached_text = text
          self.cached_type = text_type
          self.hot_reload()
  
      def loadResource(self, resource_type: QTextDocument.ResourceType, url: QUrl) -> Any:
          """Load a resource from an url.
  
          If resource type is an image and the url is external, download it using requests and cache it.
          """
          if resource_type == QTextDocument.ResourceType.ImageResource and not url.isLocalFile():
              image: QImage = QImage()
              if url.toDisplayString() not in self.remote_image_cache:
                  reply = app().session.get(url)
  
                  def handle_reply():
                      data: bytes = reply.readAll()
                      image.loadFromData(data)
                      self.remote_image_cache[url.toDisplayString()] = data
  
                      if self.cached_type:
                          self.hot_reload()
                      reply.deleteLater()
  
                  reply.finished.connect(handle_reply)
              else:
                  image.loadFromData(self.remote_image_cache[url.toDisplayString()])
              return image
          else:
              return super().loadResource(int(resource_type), url)
  
      def setLineWrapMode(self, mode: int | QTextEdit.LineWrapMode) -> None:
          """Set the line wrap mode. Allows use of ints."""
          super().setLineWrapMode(QTextEdit.LineWrapMode(mode))
  
      # noinspection PyTypeChecker
      def connect_key_to(self, key: Qt.Key, func: Callable) -> None:
          """Connect a :py:class:`Callable` to a key press."""
          self.key_callable_map[int(key)] = func
  
      def keyPressEvent(self, event: QKeyEvent) -> None:
          """Execute :py:class:`Callable` mapped to the key press."""
          super().keyPressEvent(event)
          self.key_callable_map[event.key()]()
  
  
  class PasteLineEdit(QLineEdit):
      """A :py:class:`QLineEdit` with an added paste listener."""
      pasted = Signal(name='pasted')
  
      def __init__(self, *args, pasted: Optional[Callable] = None, **kwargs) -> None:
          super().__init__(*args, **kwargs)
          if pasted is not None:
              self.pasted.connect(pasted)
  
      def keyPressEvent(self, event: QKeyEvent) -> None:
          """Call self.pasted on paste."""
          super().keyPressEvent(event)
          if event.matches(QKeySequence.Paste):
              self.pasted.emit()
  
  
  class HistoryComboBox(QComboBox):
      """Editable :py:class:`QComboBox` acting as a history wrapper over :py:class:`BetterLineEdit`; has no duplicate values."""
      line_edit_class = PasteLineEdit
  
      def __init__(self, *args, **kwargs) -> None:
          super().__init__(*args, **kwargs)
          self.setEditable(True)
          self.setDuplicatesEnabled(False)
          self.setLineEdit(self.line_edit_class(parent=self))
  
      # noinspection PyTypeChecker
      def addItem(self, text: str, **kwargs) -> None:
          """Filters already-present strings from being added using addItem.
  
          addItem(self,
          icon: Union[PySide6.QtGui.QIcon, PySide6.QtGui.QPixmap],
          text: str,
          userData: Any = Invalid(typing.Any)
          ) -> None
          addItem(self,
          text: str,
          userData: Any = Invalid(typing.Any)
          ) -> None"""
          result = self.findText(text, Qt.MatchFlag.MatchFixedString)
          if result != -1:
              self.removeItem(result)
  
          super().addItem(text, **kwargs)
  
      def addItems(self, texts: Sequence[str], **_) -> None:
          """self.addItem for text in texts."""
          for text in texts:
              self.addItem(text)
  
  
  # TODO: Zoom In/Out Buttons
  class LicenseViewer(QWidget):
      """Widget that formats and shows the project's (and all of its requirements') license files."""
      LICENSE_DATA: Final[dict[str, tuple[str, str]]] = current_requirement_licenses(PARENT_PACKAGE)
  
      def __init__(self, *args, **kwargs) -> None:
          """Create a new LicenseViewer. Gets license from the HI_RESOURCE_PATH/LICENSE file
  
          Has a fixed size of 750x380.
          """
          super().__init__(*args, **kwargs)
          self.setWindowTitle(app().translator('gui.license_viewer.title'))
          self.setWindowIcon(app().icon_store['copyright'])
          self.resize(QSize(750, 550))
          self.current_license_index = 0
  
          self.license_label:       QLabel
          self.license_index_label: QLabel
          self.license_text_edit:   ExternalTextBrowser
          self.next_license_button: QPushButton
          self.prev_license_button: QPushButton
          self._init_ui()
  
      def _init_ui(self) -> None:
          self.license_label:       QLabel = QLabel(self)
          self.license_index_label: QLabel = QLabel(f'{self.current_license_index + 1} of {len(self.LICENSE_DATA)}', self)
          self.license_text_edit:   ExternalTextBrowser = ExternalTextBrowser(self)
          self.next_license_button: QPushButton = QPushButton(app().translator('gui.license_viewer.next'), clicked=self.next_license)
          self.prev_license_button: QPushButton = QPushButton(app().translator('gui.license_viewer.previous'), clicked=self.prev_license)
  
          self.license_text_edit.connect_key_to(Qt.Key_Left, self.prev_license)
          self.license_text_edit.connect_key_to(Qt.Key_Right, self.next_license)
          self.view_package(PARENT_PACKAGE)
  
          layout = QVBoxLayout(self)
          top = QHBoxLayout()
  
          layout.addLayout(top)
          top.addWidget(self.license_label)
          top.addWidget(self.prev_license_button)
          top.addWidget(self.next_license_button)
          top.addWidget(self.license_index_label)
          layout.addWidget(self.license_text_edit)
  
          cursor = self.license_text_edit.textCursor()
          cursor.clearSelection()
          cursor.select(QTextCursor.SelectionType.Document)
          self.next_license_button.setMaximumWidth(100)
          self.prev_license_button.setMaximumWidth(100)
          self.license_index_label.setMaximumWidth(50)
          self.license_text_edit.setFont(QFont('consolas', 11))
          self.license_text_edit.setOpenExternalLinks(True)
  
      def next_license(self) -> None:
          """View the next license."""
          self.current_license_index += 1
          if self.current_license_index + 1 > len(self.LICENSE_DATA):
              self.current_license_index = 0
          self.view_current_index()
  
      def prev_license(self) -> None:
          """View the previous license."""
          self.current_license_index -= 1
          if self.current_license_index < 0:
              self.current_license_index = len(self.LICENSE_DATA) - 1
          self.view_current_index()
  
      def view_current_index(self) -> None:
          """Views the license data at the current index."""
          self.view_package(tuple(self.LICENSE_DATA)[self.current_license_index])
  
      def view_package(self, package: str) -> None:
          """Views the license data of the given package name."""
          license_text = self.LICENSE_DATA[package][1] or app().translator('gui.license_viewer.not_found')
          self.current_license_index = tuple(self.LICENSE_DATA).index(package)
          self.license_label.setText(f'{package} -- "{self.LICENSE_DATA[package][0]}" {app().translator("gui.license_viewer.license")}')
          self.license_index_label.setText(f'{self.current_license_index + 1} of {len(self.LICENSE_DATA)}')
  
          output = license_text
          replaced = set()
          for match in HI_URL_PATTERN.finditer(license_text):
              match = match[0]
              if match not in replaced:
                  output = output.replace(match, f'<a href="{match}" style="color: #2A5DB0">{match}</a>')
                  replaced.add(match)
  
          stripped_output = ''
          for line in output.splitlines():
              stripped_output += line.strip() + '\n'
          stripped_output = stripped_output.strip()
          self.license_text_edit.setHtml(
              f'<body style="white-space: pre-wrap">'
              f'<center>{stripped_output}</center>'
              f'</body>'
          )
  
          scroll_to_top(self.license_text_edit)
  
  
  class ReadmeViewer(QWidget):
      """Widget that formats and shows the project's README.md, stored in the projects 'Description' metadata tag."""
      README_TEXT: Final[str] = metadata(PARENT_PACKAGE)['Description']
  
      def __init__(self, *args, **kwargs) -> None:
          super().__init__(*args, **kwargs)
          self.setWindowTitle(app().translator('gui.readme_viewer.title'))
          self.setWindowIcon(self.style().standardIcon(QStyle.SP_DialogApplyButton))
          self.resize(QSize(750, 750))
          self._init_ui()
  
      def _dummy_func(self) -> None:
          """Must exist otherwise ReadmeViewer instances will be garbage collected through Context Menu deletion. Don't ask, just accept."""
  
      def _init_ui(self) -> None:
          readme_viewer = ExternalTextBrowser(self)
          close_button = QPushButton("Close", self, clicked=self.close)
  
          readme_viewer.connect_key_to(Qt.Key_Any, self._dummy_func)  # Refer to self._dummy_func.__doc__
  
          layout = QVBoxLayout(self)
          self.setLayout(layout)
  
          layout.addWidget(readme_viewer)
          layout.addWidget(close_button)
  
          readme_viewer.setOpenExternalLinks(True)
          readme_viewer.set_hot_reloadable_text(self.README_TEXT, 'markdown')
          readme_viewer.setFont(QFont(readme_viewer.font().family(), 10))
          close_button.setMinimumHeight(40)
          close_button.setFont(QFont(close_button.font().family(), 16))
  