980bac5c4e
The dialog would default to CWD because the contents of the line edit where not an absolute path.
341 lines
12 KiB
Python
341 lines
12 KiB
Python
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])
|