import os from enum import IntEnum from logging import getLogger from typing import Callable, Tuple, Optional, Dict, List from PyQt5.QtCore import ( QSize, pyqtSignal, QFileInfo, QRunnable, QObject, QThreadPool, pyqtSlot, QDir, ) from PyQt5.QtWidgets import ( QSizePolicy, QLabel, QFileDialog, QHBoxLayout, QWidget, QLineEdit, QToolButton, QCompleter, QFileSystemModel, QStyledItemDelegate, QFileIconProvider, QPushButton, ) from rare.utils.misc import qta_icon logger = getLogger("IndicatorEdit") class IndicatorReasonsCommon(IntEnum): VALID = 0 UNDEFINED = 1 EMPTY = 2 WRONG_FORMAT = 3 WRONG_PATH = 4 DIR_NOT_EMPTY = 5 DIR_NOT_EXISTS = 6 FILE_NOT_EXISTS = 7 NOT_INSTALLED = 8 class IndicatorReasons(IntEnum): """ Empty enumeration with auto-generated enumeration values. Extend this class per-case to implement dedicated message types. Types should be assigned using `auto()` from enum example: MyReasons(IndicatorReasons): MY_REASON = auto() """ @staticmethod def _generate_next_value_(name, start, count, last_values): """generate consecutive automatic numbers starting from zero""" start = len(IndicatorReasonsCommon) return IntEnum._generate_next_value_(name, start, count, last_values) class IndicatorReasonsStrings(QObject): def __init__(self, parent=None): super(IndicatorReasonsStrings, self).__init__(parent=parent) self.__text = { IndicatorReasonsCommon.VALID: self.tr("Ok!"), IndicatorReasonsCommon.UNDEFINED: self.tr("Unknown error occurred"), IndicatorReasonsCommon.EMPTY: self.tr("Value can not be empty"), IndicatorReasonsCommon.WRONG_FORMAT: self.tr("Wrong format"), IndicatorReasonsCommon.WRONG_PATH: self.tr("Wrong file or directory"), IndicatorReasonsCommon.DIR_NOT_EMPTY: self.tr("Directory is not empty"), IndicatorReasonsCommon.DIR_NOT_EXISTS: self.tr("Directory does not exist"), IndicatorReasonsCommon.FILE_NOT_EXISTS: self.tr("File does not exist"), IndicatorReasonsCommon.NOT_INSTALLED: self.tr("Game is not installed or does not exist"), } def __getitem__(self, item: int) -> str: return self.__text[item] def __setitem__(self, key: int, value: str): self.__text[key] = value def extend(self, reasons: Dict): for k in self.__text.keys(): if k in reasons.keys(): raise RuntimeError(f"{reasons} contains existing values") self.__text.update(reasons) class EditFuncRunnable(QRunnable): class Signals(QObject): result = pyqtSignal(bool, str, int) def __init__(self, func: Callable[[str], Tuple[bool, str, int]], args: str): super(EditFuncRunnable, self).__init__() self.setAutoDelete(True) self.signals = EditFuncRunnable.Signals() self.func = self.__wrap_edit_function(func) self.args = args def run(self): o0, o1, o2 = self.func(self.args) self.signals.result.emit(o0, o1, o2) self.signals.deleteLater() @staticmethod def __wrap_edit_function(func: Callable[[str], Tuple[bool, str, int]]): if func: return lambda text: func(os.path.expanduser(text) if text.startswith("~") else text) else: return func class IndicatorLineEdit(QWidget): textChanged = pyqtSignal(str) def __init__( self, text: str = "", placeholder: str = "", completer: QCompleter = None, edit_func: Callable[[str], Tuple[bool, str, int]] = None, save_func: Callable[[str], None] = None, horiz_policy: QSizePolicy.Policy = QSizePolicy.Expanding, parent=None, ): super(IndicatorLineEdit, self).__init__(parent=parent) self.setObjectName(type(self).__name__) layout = QHBoxLayout(self) layout.setObjectName(f"{self.objectName()}Layout") layout.setContentsMargins(0, 0, 0, 0) # Add line_edit self.line_edit = QLineEdit(self) self.line_edit.setObjectName(f"{type(self).__name__}Edit") self.line_edit.setPlaceholderText(placeholder if placeholder else self.tr("Use global/default settings")) self.line_edit.setToolTip(placeholder if placeholder else "") self.line_edit.setSizePolicy(horiz_policy, QSizePolicy.Fixed) # Add completer self.setCompleter(completer) layout.addWidget(self.line_edit) if edit_func is not None: self.indicator_label = QLabel(self) self.indicator_label.setPixmap(qta_icon("ei.info-circle", color="gray").pixmap(16, 16)) self.indicator_label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) layout.addWidget(self.indicator_label) self.__reasons = IndicatorReasonsStrings(self) self.__threadpool = QThreadPool(self) self.__threadpool.setMaxThreadCount(1) self.__thread: Optional[EditFuncRunnable] = None self.is_valid = False self.edit_func = edit_func self.save_func = save_func if text: self.line_edit.setText(text) self.line_edit.textChanged.connect(self.__edit) if self.edit_func is None: self.line_edit.textChanged.connect(self.__save) # lk: this can be placed here to trigger __edit # lk: it is going to save the input again if it is valid which # lk: is ok to do given the checks don't misbehave (they shouldn't) # lk: however it is going to edit any "understood" bad input to good input # lk: and we might not want that (but the validity check reports on the edited string) # lk: it is also going to trigger this widget's textChanged signal but that gets lost # if text: # self.line_edit.setText(text) def deleteLater(self) -> None: if self.__thread is not None: self.__thread.signals.result.disconnect() super(IndicatorLineEdit, self).deleteLater() def text(self) -> str: return self.line_edit.text() def setText(self, text: str): self.line_edit.setText(text) def setCompleter(self, completer: Optional[QCompleter]): if old := self.line_edit.completer(): old.deleteLater() if not completer: self.line_edit.setCompleter(None) return completer.popup().setItemDelegate(QStyledItemDelegate(self)) completer.popup().setAlternatingRowColors(True) self.line_edit.setCompleter(completer) def extend_reasons(self, reasons: Dict): self.__reasons.extend(reasons) def __indicator(self, valid, reason: int = 0): color = "green" if valid else "red" self.indicator_label.setPixmap(qta_icon("ei.info-circle", color=color).pixmap(16, 16)) if not valid: self.indicator_label.setToolTip(self.__reasons[reason]) else: self.indicator_label.setToolTip(self.__reasons[IndicatorReasonsCommon.VALID]) @pyqtSlot(bool, str, int) def __edit_handler(self, is_valid: bool, text: str, reason: int): self.__thread = None self.line_edit.blockSignals(True) if text != self.line_edit.text(): self.line_edit.setText(text) self.line_edit.blockSignals(False) self.__indicator(is_valid, reason) if is_valid: self.__save(text) self.is_valid = is_valid self.textChanged.emit(text) def __edit(self, text): if self.edit_func is not None: if self.__thread is not None: self.__thread.signals.result.disconnect(self.__edit_handler) self.__thread = EditFuncRunnable(self.edit_func, text) self.__thread.signals.result.connect(self.__edit_handler) self.__threadpool.start(self.__thread) def __save(self, text): if self.save_func is not None: self.save_func(text) class PathEditIconProvider(QFileIconProvider): icons = [ ("mdi.file-cancel", "fa.file-excel-o"), # Unknown ("mdi.desktop-classic", "fa.desktop"), # Computer ("mdi.desktop-mac", "fa.desktop"), # Desktop ("mdi.trash-can", "fa.trash"), # Trashcan ("mdi.server-network", "fa.server"), # Network ("mdi.harddisk", "fa.desktop"), # Drive ("mdi.folder", "fa.folder"), # Folder ("mdi.file", "fa.file"), # File ("mdi.cog", "fa.cog"), # Executable ] def __init__(self): super(PathEditIconProvider, self).__init__() self.icon_types = {} for idx, (icn, fallback) in enumerate(PathEditIconProvider.icons): self.icon_types.update({idx - 1: qta_icon(icn, fallback, color="#eeeeee")}) def icon(self, info_type): if isinstance(info_type, QFileInfo): if info_type.isRoot(): return self.icon_types[4] if info_type.isDir(): return self.icon_types[5] if info_type.isFile(): return self.icon_types[6] if info_type.isExecutable(): return self.icon_types[7] return self.icon_types[-1] return self.icon_types[int(info_type)] class PathEdit(IndicatorLineEdit): def __init__( self, path: str = "", file_mode: QFileDialog.FileMode = QFileDialog.AnyFile, file_filter: QDir.Filters = 0, name_filters: List[str] = None, placeholder: str = "", edit_func: Callable[[str], Tuple[bool, str, int]] = None, save_func: Callable[[str], None] = None, horiz_policy: QSizePolicy.Policy = QSizePolicy.Expanding, parent=None, ): self.__root_path = path if path else os.path.expanduser("~/") self.__completer = QCompleter() self.__completer_model = QFileSystemModel(self.__completer) try: self.__completer_model.setOptions( QFileSystemModel.DontWatchForChanges | QFileSystemModel.DontResolveSymlinks | QFileSystemModel.DontUseCustomDirectoryIcons ) except AttributeError as e: # Error on Ubuntu logger.warning(e) self.__completer_model.setIconProvider(PathEditIconProvider()) self.__completer_model.setRootPath(path) if file_filter: self.__completer_model.setFilter(file_filter) if name_filters: self.__completer_model.setNameFilters(name_filters) self.__completer.setModel(self.__completer_model) super(PathEdit, self).__init__( text=path, placeholder=placeholder, completer=self.__completer, edit_func=edit_func, save_func=save_func, horiz_policy=horiz_policy, parent=parent, ) self.setObjectName(type(self).__name__) self.line_edit.setMinimumSize(QSize(250, 0)) self.path_select = QPushButton(self) self.path_select.setObjectName(f"{type(self).__name__}Button") layout = self.layout() layout.addWidget(self.path_select) self.path_select.setText(self.tr("Browse...")) self.__file_mode = file_mode self.__file_filter = file_filter self.__name_filter = name_filters self.path_select.clicked.connect(self.__set_path) def setRootPath(self, path: str): self.__root_path = path self.__completer_model.setRootPath(path) def __set_path(self): dlg_path = self.line_edit.text() if not dlg_path or not os.path.isabs(dlg_path): dlg_path = self.__root_path dlg = QFileDialog(self, self.tr("Choose path"), dlg_path) dlg.setOption(QFileDialog.DontUseCustomDirectoryIcons) dlg.setIconProvider(PathEditIconProvider()) dlg.setFileMode(self.__file_mode) if self.__file_mode == QFileDialog.Directory: dlg.setOption(QFileDialog.ShowDirsOnly, True) if self.__file_filter: dlg.setFilter(self.__file_filter) if self.__name_filter: dlg.setNameFilter(" ".join(self.__name_filter)) if dlg.exec_(): names = dlg.selectedFiles() self.line_edit.setText(names[0]) self.__completer_model.setRootPath(names[0])