diff options
Diffstat (limited to 'libs/installer_wizard/src/dialog.py')
| -rw-r--r-- | libs/installer_wizard/src/dialog.py | 752 |
1 files changed, 752 insertions, 0 deletions
diff --git a/libs/installer_wizard/src/dialog.py b/libs/installer_wizard/src/dialog.py new file mode 100644 index 0000000..76d10bb --- /dev/null +++ b/libs/installer_wizard/src/dialog.py @@ -0,0 +1,752 @@ +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Any, cast + +from antlr4 import ParserRuleContext +from PyQt6 import QtWidgets +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QFontDatabase, QKeySequence, QPixmap, QResizeEvent, QShortcut +from PyQt6.QtWidgets import QApplication + +from wizard.contexts import ( + WizardInterpreterContext, + WizardRequireVersionsContext, + WizardSelectContext, + WizardSelectManyContext, + WizardSelectOneContext, + WizardTerminationContext, + WizardTopLevelContext, +) +from wizard.errors import WizardError +from wizard.interpreter import WizardInterpreter +from wizard.manager import SelectOption +from wizard.runner import WizardRunnerKeywordVisitor, WizardRunnerState +from wizard.tweaks import WizardINISetting +from wizard.value import Plugin + +import mobase + +from .ui.wizardinstallercomplete import Ui_WizardInstallerComplete +from .ui.wizardinstallerdialog import Ui_WizardInstallerDialog +from .ui.wizardinstallererror import Ui_WizardInstallerError +from .ui.wizardinstallerpage import Ui_WizardInstallerPage +from .ui.wizardinstallerrequires import Ui_WizardInstallerRequires +from .utils import make_ini_tweaks + +WizardRunnerContext = WizardInterpreterContext[WizardRunnerState, Any] + + +def check_version( + context: WizardRequireVersionsContext[WizardRunnerState], + organizer: mobase.IOrganizer, +) -> tuple[bool, bool, bool, bool]: + """ + Check if the requirements are ok. + + Args: + context: The requires version context to check. + organizer: The organizer to fetch actual versions from. + + Returns: + A 4-tuple of boolean values, where each value is True if the installed + version is ok, False otherwise. In order, checks are game, script extender + graphics extender (True if there is no requirements, False otherwise since + we cannot check in MO2), and wrye bash (always True). + """ + game = organizer.managedGame() + + game_ok = True + if context.game_version: + game_ok = mobase.VersionInfo(context.game_version) <= game.version() + + # script extender + se_ok = True + if context.script_extender_version: + se = organizer.gameFeatures().gameFeature(mobase.ScriptExtender) + if not se or not se.isInstalled(): + se_ok = False + else: + if mobase.VersionInfo( + context.script_extender_version + ) <= mobase.VersionInfo(se.getExtenderVersion()): + se_ok = True + else: + se_ok = False + + # cannot check these so... + ge_ok = not context.graphics_extender_version + + return (game_ok, se_ok, ge_ok, True) + + +class WizardInstallerRequiresVersionPage(QtWidgets.QWidget): + context: WizardRequireVersionsContext[WizardRunnerState] + + def __init__( + self, + context: WizardRequireVersionsContext[WizardRunnerState], + organizer: mobase.IOrganizer, + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + self.context = context + + # set the ui file + self.ui = Ui_WizardInstallerRequires() + self.ui.setupUi(self) # pyright: ignore[reportUnknownMemberType] + + self.ui.groupBox.setStyleSheet( + 'QLabel[headercell="true"] { font-weight: bold; }' + ) + + game = organizer.managedGame() + + okIcon = QPixmap(":/MO/gui/checked-checkbox").scaled( + 16, + 16, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + noIcon = QPixmap(":/MO/gui/unchecked-checkbox").scaled( + 16, + 16, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + koIcon = QPixmap(":/MO/gui/indeterminate-checkbox").scaled( + 16, + 16, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + + self.ui.labelGame.setText(game.gameName()) + + # set the required version + self.ui.labelGameNeed.setText(context.game_version) + self.ui.labelScriptExtenderNeed.setText(context.script_extender_version) + self.ui.labelGraphicsExtenderNeed.setText(context.graphics_extender_version) + self.ui.labelWryeBashNeed.setText(context.wrye_bash_version) + + # set the current version + self.ui.labelGameHave.setText(game.version().canonicalString()) + se = organizer.gameFeatures().gameFeature(mobase.ScriptExtender) + if se and se.isInstalled(): + self.ui.labelScriptExtenderHave.setText(se.getExtenderVersion()) + + # cannot check these so... + game_ok, se_ok, _, _ = check_version(context, organizer) + self.ui.labelGameIcon.setPixmap(okIcon if game_ok else koIcon) + self.ui.labelScriptExtenderIcon.setPixmap(okIcon if se_ok else koIcon) + self.ui.labelGraphicsExtenderIcon.setPixmap(noIcon) + self.ui.labelWryeBashIcon.setPixmap(noIcon) + + +class WizardInstallerSelectPage(QtWidgets.QWidget): + # signal emitted when an item is double-clicked, only for SelectOne context + itemDoubleClicked = pyqtSignal() + + context: WizardSelectContext[WizardRunnerState, Any] + _images: dict[Path, Path] + _currentImage: QPixmap + + def __init__( + self, + context: WizardSelectContext[WizardRunnerState, Any], + images: dict[Path, Path], + options: Sequence[str] | None, + parent: QtWidgets.QWidget, + ): + """ + Args: + context: The context for this page. + images: A mapping from path (in the archive) to extracted path. + options: Potential list of options to select. Might not exactly match. + parent: The parent widget. + """ + super().__init__(parent) + + self._images = images + + # set the ui file + self.ui = Ui_WizardInstallerPage() + self.ui.setupUi(self) # pyright: ignore[reportUnknownMemberType] + + self.ui.optionList.currentItemChanged.connect( # pyright: ignore[reportUnknownMemberType] + self.onCurrentItemChanged + ) + + # create list item widgets + for _ in context.options: + item = QtWidgets.QListWidgetItem() + self.ui.optionList.addItem(item) + + self.update_context(context) + + # extract previous select options + previous_options = [] + if options: + previous_options = [ + option for option in context.options if option.name in options + ] + else: + if isinstance(context, WizardSelectManyContext): + previous_options = context.defaults + elif isinstance(context, WizardSelectOneContext): + previous_options = [context.default] + + # set default values + for i, option in enumerate(context.options): + item = self.ui.optionList.item(i) + assert item is not None + if isinstance(context, WizardSelectManyContext): + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + if option in previous_options: + item.setCheckState(Qt.CheckState.Checked) + else: + item.setCheckState(Qt.CheckState.Unchecked) + elif ( + isinstance(context, WizardSelectOneContext) + and option in previous_options + ): + item.setSelected(True) + self.ui.optionList.setCurrentItem(item) + + if isinstance(context, WizardSelectOneContext): + self.ui.optionList.doubleClicked.connect( # pyright: ignore[reportUnknownMemberType] + self.itemDoubleClicked.emit + ) + + def update_context(self, context: WizardSelectContext[WizardRunnerState, Any]): + self.context = context + + options = self.context.options + assert len(options) == self.ui.optionList.count() + + self.ui.selectDescriptionLabel.setText(context.description) + self.ui.selectDescriptionLabel.setMargin(4) + + # update the content of the items + for i, option in enumerate(options): + item = self.ui.optionList.item(i) + assert item is not None + item.setText(option.name) + item.setData(Qt.ItemDataRole.UserRole, option) + + # no item selected, select the first one + if not self.ui.optionList.currentItem(): + self.ui.optionList.setCurrentRow(0) + + def onCurrentItemChanged( + self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem + ): + option: SelectOption = current.data(Qt.ItemDataRole.UserRole) + self.ui.descriptionTextEdit.setText(option.description) + image = option.image + if image and Path(image) in self._images: + target = self._images[Path(image)] + self._currentImage = QPixmap(target.as_posix()) + else: + self._currentImage = QPixmap() + + self.ui.imageLabel.setPixmap(self.getResizedImage()) + + def getResizedImage(self) -> QPixmap: + if self._currentImage.isNull(): + return self._currentImage + return self._currentImage.scaled( + self.ui.imageLabel.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + + def resizeEvent(self, a0: QResizeEvent | None) -> None: + super().resizeEvent(a0) + self.ui.imageLabel.setPixmap(self.getResizedImage()) + + def selectedOptions(self) -> list[SelectOption]: + options: list[SelectOption] = [] + if isinstance(self.context, WizardSelectOneContext): + item = self.ui.optionList.currentItem() + assert item is not None + options.append(item.data(Qt.ItemDataRole.UserRole)) + else: + for i in range(self.ui.optionList.count()): + item = self.ui.optionList.item(i) + assert item is not None + if item.checkState() == Qt.CheckState.Checked: + options.append(item.data(Qt.ItemDataRole.UserRole)) + return options + + def selected(self) -> WizardSelectContext[WizardRunnerState, Any]: + if isinstance(self.context, WizardSelectOneContext): + return self.context.select(self.selectedOptions()[0]) + elif isinstance(self.context, WizardSelectManyContext): + return self.context.select(self.selectedOptions()) + else: + return self.context + + +class WizardInstallerCompletePage(QtWidgets.QWidget): + def __init__( + self, + context: WizardTerminationContext[WizardRunnerState], + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + # set the ui file + self.ui = Ui_WizardInstallerComplete() + self.ui.setupUi(self) # pyright: ignore[reportUnknownMemberType] + + self.setStyleSheet('QLabel[heading="true"] { font-weight: bold; }') + + self.context = context + self.state = context.state + + # retrieve the keyword visitor + kvisitor = cast(WizardRunnerKeywordVisitor, context.factory.kvisitor) + + # the list of plugins in selected sub-packages + plugins: set[Plugin] = set() + + # sub-packages + for sp in kvisitor.subpackages: + item = QtWidgets.QListWidgetItem() + item.setText(sp.name) + if sp.name in self.state.subpackages: + item.setCheckState(Qt.CheckState.Checked) + else: + item.setCheckState(Qt.CheckState.Unchecked) + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + plugins.update(kvisitor.plugins_for(sp)) + self.ui.subpackagesList.addItem(item) + + # switch the renamed plugins + for plugin in list(plugins): + if plugin in self.state.renames: + plugins.remove(plugin) + plugins.add(Plugin(self.state.renames[plugin])) + + # lugins + for plugin in sorted(plugins): + item = QtWidgets.QListWidgetItem() + item.setText(plugin.name) + if plugin in self.state.plugins: + item.setCheckState(Qt.CheckState.Checked) + else: + item.setCheckState(Qt.CheckState.Unchecked) + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + self.ui.pluginsList.addItem(item) + + # INI Tweaks + self.ui.tweaksWidget.setVisible(bool(self.state.tweaks)) + self.ui.tweaksList.currentItemChanged.connect( # pyright: ignore[reportUnknownMemberType] + self.onCurrentTweakItemChanged + ) + if self.state.tweaks: + # group the tweaks per file + tweaks = { + file: self.state.tweaks.tweaks(file) + for file in self.state.tweaks.files() + } + + for file, ftweaks in tweaks.items(): + item = QtWidgets.QListWidgetItem() + item.setText(file.replace("\\", "/")) + item.setData(Qt.ItemDataRole.UserRole, ftweaks) + self.ui.tweaksList.addItem(item) + + self.ui.tweaksTextEdit.setFont( + QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) + ) + self.ui.tweaksList.setCurrentRow(0) + + # notes + md = "" + for note in self.state.notes: + md += f"- {note}\n" + document = self.ui.notesTextEdit.document() + assert document is not None + document.setIndentWidth(10) + self.ui.notesTextEdit.setMarkdown(md) + + def onCurrentTweakItemChanged( + self, current: QtWidgets.QListWidgetItem, previous: QtWidgets.QListWidgetItem + ): + # clear text area and create the tweaks + self.ui.tweaksTextEdit.clear() + self.ui.tweaksTextEdit.appendPlainText( + make_ini_tweaks(current.data(Qt.ItemDataRole.UserRole)) + ) + + def subpackages(self) -> list[str]: + """ + Returns: + The list of subpackages selected in the UI (either automatically by the + interpreter or by the user). + """ + sp: list[str] = [] + for i in range(self.ui.subpackagesList.count()): + item = self.ui.subpackagesList.item(i) + assert item is not None + if item.checkState() == Qt.CheckState.Checked: + sp.append(item.text()) + return sp + + def plugins(self) -> dict[str, bool]: + """ + Returns: + The list of plugins selected in the UI (either automatically by the + interpreter or by the user). + """ + sp: dict[str, bool] = {} + for i in range(self.ui.pluginsList.count()): + item = self.ui.pluginsList.item(i) + assert item is not None + sp[item.text()] = item.checkState() == Qt.CheckState.Checked + return sp + + def tweaks(self) -> dict[str, list[WizardINISetting]]: + """ + Returns: + The list of tweaks created by the wizard. The returned value maps filenames + to INI tweaks. + """ + rets: dict[str, list[WizardINISetting]] = {} + for i in range(self.ui.tweaksList.count()): + item = self.ui.tweaksList.item(i) + assert item is not None + rets[item.text()] = item.data(Qt.ItemDataRole.UserRole) + return rets + + +class WizardInstallerCancelPage(QtWidgets.QWidget): + def __init__( + self, + context: WizardTerminationContext[WizardRunnerState], + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + # set the ui file (same UI file for both cancel and error) + self.ui = Ui_WizardInstallerError() + self.ui.setupUi(self) # pyright: ignore[reportUnknownMemberType] + + self.ui.titleLabel.setText( + "The installation was cancelled by the installer with the following reason." + ) + style = self.style() + assert style is not None + self.ui.iconLabel.setPixmap( + style.standardIcon( + QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning + ).pixmap(24, 24) + ) + self.ui.messageEdit.setText(context.message()) + + +class WizardInstallerErrorPage(QtWidgets.QWidget): + def __init__( + self, + error: WizardError, + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + # set the ui file (same UI file for both cancel and error) + self.ui = Ui_WizardInstallerError() + self.ui.setupUi(self) # pyright: ignore[reportUnknownMemberType] + + self.ui.titleLabel.setText( + "An error occurred during the installation of the script, " + "this is probably due to an incorrect script file (wizard.txt) in the " + "archive." + ) + style = self.style() + assert style is not None + self.ui.iconLabel.setPixmap( + style.standardIcon( + QtWidgets.QStyle.StandardPixmap.SP_MessageBoxCritical + ).pixmap(24, 24) + ) + self.ui.messageEdit.setText(str(error)) + + +class WizardInstallerDialog(QtWidgets.QDialog): + # flag to indicate if the user chose to do a manual installation + _manual: bool = False + + # organizer + _organizer: mobase.IOrganizer + + # interpreter + _interpreter: WizardInterpreter[WizardRunnerState] + _images: dict[Path, Path] + _options: dict[str, list[str]] + + # the Wizard MO2 interface + _start_context: WizardTopLevelContext[WizardRunnerState] + + # dict from context to selected options + _pages: dict[ParserRuleContext, WizardInstallerSelectPage] + + def __init__( + self, + organizer: mobase.IOrganizer, + interpreter: WizardInterpreter[WizardRunnerState], + context: WizardTopLevelContext[WizardRunnerState], + name: mobase.GuessedString, + images: dict[Path, Path], + options: dict[str, list[str]], + parent: QtWidgets.QWidget, + ): + """ + Args: + interpreter: The interpreter to use. + context: The initial context of the script. + name: The name of the mod. + images: A mapping from path (in the archive) to extracted path. + options: The previously selected options. + parent: The parent widget. + """ + super().__init__(parent) + + self._organizer = organizer + self._interpreter = interpreter + self._images = images + self._options = options + self._start_context = context + self._pages = {} + + # set the ui file + self.ui = Ui_WizardInstallerDialog() + self.ui.setupUi(self) # pyright: ignore[reportUnknownMemberType] + + self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False) + self.setWindowFlag(Qt.WindowType.WindowMaximizeButtonHint, True) + + # mobase.GuessedString contains multiple names with various level of + # "guess", using.variants() returns the list of names, and doing str(name) + # will return the most-likely value + for value in name.variants(): + self.ui.nameCombo.addItem(value) + completer = self.ui.nameCombo.completer() + assert completer is not None + completer.setCaseSensitivity(Qt.CaseSensitivity.CaseSensitive) + self.ui.nameCombo.setCurrentIndex(self.ui.nameCombo.findText(str(name))) + + # we need to connect the Cancel / Manual buttons. We can of course use + # PyQt6 signal/slot syntax + self.ui.cancelBtn.clicked.connect( # pyright: ignore[reportUnknownMemberType] + self.reject + ) + + def manualClicked(): + self._manual = True + self.reject() + + self.ui.manualBtn.clicked.connect( # pyright: ignore[reportUnknownMemberType] + manualClicked + ) + + self.ui.prevBtn.clicked.connect( # pyright: ignore[reportUnknownMemberType] + self.previousClicked + ) + self.ui.nextBtn.clicked.connect( # pyright: ignore[reportUnknownMemberType] + self.nextClicked + ) + + backShortcut = QShortcut(QKeySequence(Qt.Key.Key_Backspace), self) + backShortcut.activated.connect( # pyright: ignore[reportUnknownMemberType] + self.previousClicked + ) + + @property + def scriptButtonClicked(self) -> pyqtSignal: + return self.ui.scriptBtn.clicked # pyright: ignore[reportReturnType] + + def name(self): + return self.ui.nameCombo.currentText() + + def subpackages(self): + """ + Returns: + The list of subpackages to install. Only valid if exec() returned + Accepted. + """ + # we cannot fetch it from the state since the user can modify it in the UI + widget = self.ui.stackedWidget.currentWidget() + assert isinstance(widget, WizardInstallerCompletePage) + return widget.subpackages() + + def plugins(self) -> dict[str, bool]: + """ + Returns: + The list of plugins to install and enable. Only valid if exec() returned + Accepted. + """ + # we cannot fetch it from the state since the user can modify it in the UI + widget = self.ui.stackedWidget.currentWidget() + assert isinstance(widget, WizardInstallerCompletePage) + return widget.plugins() + + def renames(self) -> dict[str, str]: + """ + Returns: + The mapping of renames for plugins. Only valid if exec() returned Accepted. + """ + widget = self.ui.stackedWidget.currentWidget() + assert isinstance(widget, WizardInstallerCompletePage) + return { + plugin.name: new_name for plugin, new_name in widget.state.renames.items() + } + + def tweaks(self) -> dict[str, list[WizardINISetting]]: + """ + Returns: + The list of tweaks per file. Only valid if exec() returned Accepted. + """ + widget = self.ui.stackedWidget.currentWidget() + assert isinstance(widget, WizardInstallerCompletePage) + return widget.tweaks() + + def selectedOptions(self) -> dict[str, list[str]]: + """ + Returns: + The list of all currently selected options. + """ + result: dict[str, list[str]] = {} + for i in range(self.ui.stackedWidget.count()): + page = self.ui.stackedWidget.widget(i) + if isinstance(page, WizardInstallerSelectPage): + result[page.context.description] = [ + option.name for option in page.selectedOptions() + ] + return result + + def isManualRequested(self): + return self._manual + + def previousClicked(self): + index = self.ui.stackedWidget.currentIndex() + if index > 0: + self.ui.stackedWidget.removeWidget(self.ui.stackedWidget.widget(index)) + + self._update_prev_button() + self._update_next_button() + self._update_focus() + + def nextClicked(self): + widget = self.ui.stackedWidget.currentWidget() + + try: + if isinstance(widget, WizardInstallerSelectPage): + context = widget.selected().exec() + elif isinstance(widget, WizardInstallerRequiresVersionPage): + context = widget.context.exec() + else: + self.accept() + return + + context = self._exec_until(context) + + if context.context in self._pages: + page = self._pages[context.context] + assert isinstance(context, WizardSelectContext) + page.update_context(context) + else: + page = self._make_page(context) + + except WizardError as ex: + page = WizardInstallerErrorPage(ex, self) + + index = self.ui.stackedWidget.addWidget(page) + self.ui.stackedWidget.setCurrentIndex(index) + self._update_prev_button() + self._update_next_button() + self._update_focus() + + def _update_focus(self): + widget = self.ui.stackedWidget.currentWidget() + if isinstance(widget, WizardInstallerSelectPage): + widget.ui.optionList.setFocus() + + def _update_prev_button(self): + self.ui.prevBtn.setDisabled(self.ui.stackedWidget.currentIndex() <= 0) + + def _update_next_button(self): + widget = self.ui.stackedWidget.currentWidget() + + self.ui.nextBtn.setDisabled(False) + + name: str = self.ui.nextBtn.text() + if isinstance(widget, WizardInstallerSelectPage): + name = self.tr("Next") + elif isinstance(widget, WizardInstallerRequiresVersionPage): + name = self.tr("Install anyway") + elif isinstance(widget, (WizardInstallerCancelPage, WizardInstallerErrorPage)): + self.ui.nextBtn.setDisabled(True) + else: + name = self.tr("Install") + + self.ui.nextBtn.setText(name) + + def _exec_until(self, context: WizardRunnerContext) -> WizardRunnerContext: + context = self._interpreter.exec_until( + context, + ( + WizardSelectContext, + WizardRequireVersionsContext, + ), + ) + + # if all requirements are ok, skip the context + if isinstance(context, WizardRequireVersionsContext): + if all(check_version(context, self._organizer)): + return self._exec_until(context.exec()) + + return context + + def _make_page(self, context: WizardRunnerContext) -> QtWidgets.QWidget: + page: QtWidgets.QWidget + if isinstance(context, WizardSelectContext): + page = WizardInstallerSelectPage( + context, + self._images, + self._options.get(context.description, None), + self, + ) + page.itemDoubleClicked.connect( # pyright: ignore[reportUnknownMemberType] + self.nextClicked + ) + self._pages[context.context] = page # type: ignore + elif isinstance(context, WizardRequireVersionsContext): + page = WizardInstallerRequiresVersionPage(context, self._organizer, self) + elif isinstance(context, WizardTerminationContext): + if context.is_cancel(): + page = WizardInstallerCancelPage(context, self) + else: + page = WizardInstallerCompletePage(context, self) + else: + raise NotImplementedError() # for typing purpose + + return page + + def exec(self): + try: + context = self._exec_until(self._start_context) + page = self._make_page(context) + except WizardError as ex: + page = WizardInstallerErrorPage(ex, self) + self.ui.stackedWidget.addWidget(page) + self._update_prev_button() + self._update_next_button() + self._update_focus() + return super().exec() + + def tr(self, value: str): # pyright: ignore[reportIncompatibleMethodOverride] + return QApplication.translate("WizardInstallerDialog", value) |
