예제 #1
0
def test_open_edit_connection(
    mock_apply_edit_connections,
    mock_connections_editor,
    accept: bool,
    echoes_game_data,
    skip_qtbot,
):
    # Setup
    window = DataEditorWindow(echoes_game_data, True)
    skip_qtbot.addWidget(window)

    editor = mock_connections_editor.return_value
    editor.exec_.return_value = QDialog.Accepted if accept else QDialog.Rejected

    # Run
    window._open_edit_connection()

    # Assert
    mock_connections_editor.assert_called_once_with(window,
                                                    window.resource_database,
                                                    ANY)
    if accept:
        mock_apply_edit_connections.assert_called_once_with(
            window, window.current_node, window.current_connection_node,
            editor.final_requirement)
    else:
        mock_apply_edit_connections.assert_not_called()
예제 #2
0
def test_save_database_integrity_failure(tmp_path, echoes_game_data,
                                         skip_qtbot, mocker):
    # Setup
    mock_find_database_errors = mocker.patch(
        "randovania.game_description.integrity_check.find_database_errors",
        return_value=["DB Errors", "Unknown"])
    mock_write_human_readable_game = mocker.patch(
        "randovania.game_description.pretty_print.write_human_readable_game")
    mock_create_new = mocker.patch(
        "randovania.gui.lib.scroll_message_box.ScrollMessageBox.create_new")
    mock_create_new.return_value.exec_.return_value = QtWidgets.QMessageBox.No

    tmp_path.joinpath("test-game", "game").mkdir(parents=True)
    tmp_path.joinpath("human-readable").mkdir()

    db_path = Path(tmp_path.joinpath("test-game", "game"))

    window = DataEditorWindow(echoes_game_data, db_path, True, True)
    skip_qtbot.addWidget(window)

    # Run
    window._save_as_internal_database()

    # Assert
    mock_find_database_errors.assert_called_once_with(window.game_description)
    mock_write_human_readable_game.assert_not_called()
    mock_create_new.assert_called_once_with(
        window, QtWidgets.QMessageBox.Icon.Critical, "Integrity Check",
        "Database has the following errors:\n\nDB Errors\n\nUnknown\n\nIgnore?",
        QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
        QtWidgets.QMessageBox.No)
예제 #3
0
async def test_open_edit_connection(
    mock_connections_editor: MagicMock,
    accept: bool,
    echoes_game_data,
    skip_qtbot,
    mocker,
):
    # Setup
    execute_dialog = mocker.patch(
        "randovania.gui.lib.async_dialog.execute_dialog",
        new_callable=AsyncMock)
    window = DataEditorWindow(echoes_game_data, None, False, True)
    window.editor = MagicMock()
    skip_qtbot.addWidget(window)

    editor = mock_connections_editor.return_value
    execute_dialog.return_value = QtWidgets.QDialog.Accepted if accept else QtWidgets.QDialog.Rejected

    # Run
    await window._open_edit_connection()

    # Assert
    mock_connections_editor.assert_called_once_with(window,
                                                    window.resource_database,
                                                    ANY)
    if accept:
        window.editor.edit_connections.assert_called_once_with(
            window.current_area, window.current_node,
            window.current_connection_node, editor.final_requirement)
    else:
        window.editor.edit_connections.assert_not_called()
예제 #4
0
    def _open_data_editor_prompt(self):
        database_path = common_qt_lib.prompt_user_for_database_file(self)
        if database_path is None:
            return

        with database_path.open("r") as database_file:
            self._data_editor = DataEditorWindow(json.load(database_file), True)
            self._data_editor.show()
예제 #5
0
    def _open_data_editor_prompt(self):
        from randovania.gui.data_editor import DataEditorWindow

        database_path = common_qt_lib.prompt_user_for_database_file(self)
        if database_path is None:
            return

        with database_path.open("r") as database_file:
            self._data_editor = DataEditorWindow(json.load(database_file),
                                                 database_path, False, True)
            self._data_editor.show()
예제 #6
0
    def open_data_visualizer_at(self,
                                world_name: Optional[str],
                                area_name: Optional[str],
                                ):
        self._data_visualizer = DataEditorWindow(default_data.decode_default_prime2(), False)

        if world_name is not None:
            self._data_visualizer.focus_on_world(world_name)

        if area_name is not None:
            self._data_visualizer.focus_on_area(area_name)

        self._data_visualizer.show()
예제 #7
0
def test_create_new_dock(skip_qtbot, tmp_path, blank_game_data):
    db_path = Path(tmp_path.joinpath("test-game", "game"))
    game_data = default_data.read_json_then_binary(RandovaniaGame.BLANK)[1]

    window = DataEditorWindow(game_data, db_path, True, True)
    window.set_warning_dialogs_disabled(True)
    skip_qtbot.addWidget(window)

    window.focus_on_area_by_name("Back-Only Lock Room")
    current_area = window.current_area
    target_area = window.game_description.world_list.area_by_area_location(
        AreaIdentifier("Intro", "Explosive Depot"))

    assert current_area.node_with_name("Dock to Explosive Depot") is None
    assert target_area.node_with_name("Dock to Back-Only Lock Room") is None

    # Run
    window._create_new_dock(NodeLocation(0, 0, 0), target_area)

    # Assert
    new_node = current_area.node_with_name("Dock to Explosive Depot")
    assert new_node is not None
    assert window.current_node is new_node

    assert target_area.node_with_name(
        "Dock to Back-Only Lock Room") is not None
예제 #8
0
def test_create_node_and_save(tmp_path, echoes_game_data, skip_qtbot):
    # Setup
    tmp_path.joinpath("test-game", "game").mkdir(parents=True)
    tmp_path.joinpath("human-readable").mkdir()

    db_path = Path(tmp_path.joinpath("test-game", "game"))

    window = DataEditorWindow(echoes_game_data, db_path, True, True)
    window.set_warning_dialogs_disabled(True)
    skip_qtbot.addWidget(window)

    # Run
    window._do_create_node("Some Node", None)
    window._save_as_internal_database()

    # Assert
    exported_data = data_reader.read_split_file(db_path)
    exported_game = data_reader.decode_data(exported_data)

    pretty_print.write_human_readable_game(exported_game,
                                           tmp_path.joinpath("human-readable"))
    new_files = {
        f.name: f.read_text("utf-8")
        for f in tmp_path.joinpath("human-readable").glob("*.txt")
    }

    existing_files = {
        f.name: f.read_text("utf-8")
        for f in tmp_path.joinpath("test-game", "game").glob("*.txt")
    }

    assert list(new_files.keys()) == list(existing_files.keys())
    assert new_files == existing_files
예제 #9
0
파일: qt.py 프로젝트: JaggerTSG/randovania
def show_data_editor(app: QApplication):
    from randovania.games.prime import default_data
    from randovania.gui.data_editor import DataEditorWindow

    app.data_visualizer = DataEditorWindow(
        default_data.decode_default_prime2(), True)
    app.data_visualizer.show()
예제 #10
0
def test_create_node_and_save(mock_prime2_human_readable_path,
                              mock_prime2_json_path, tmpdir, echoes_game_data,
                              skip_qtbot):
    # Setup
    mock_prime2_human_readable_path.return_value = Path(tmpdir).joinpath(
        "human")
    mock_prime2_json_path.return_value = Path(tmpdir).joinpath("database")

    window = DataEditorWindow(echoes_game_data, True)
    skip_qtbot.addWidget(window)

    # Run
    window._do_create_node("Some Node")
    window._save_as_internal_database()

    # Assert
    with mock_prime2_json_path.return_value.open() as data_file:
        exported_data = json.load(data_file)
    exported_game = data_reader.decode_data(exported_data)

    output = io.StringIO()
    data_writer.write_human_readable_world_list(exported_game, output)

    assert mock_prime2_human_readable_path.return_value.read_text(
        "utf-8") == output.getvalue()
예제 #11
0
def test_apply_edit_connections_change(
    echoes_game_data,
    qtbot,
):
    # Setup
    window = DataEditorWindow(echoes_game_data, True)
    qtbot.addWidget(window)
    game = window.game_description

    landing_site = game.world_list.area_by_asset_id(1655756413)
    source = landing_site.node_with_name("Save Station")
    target = landing_site.node_with_name("Door to Service Access")

    # Run
    window.world_selector_box.setCurrentIndex(
        window.world_selector_box.findText("Temple Grounds"))
    window.area_selector_box.setCurrentIndex(
        window.area_selector_box.findText(landing_site.name))
    window._apply_edit_connections(source, target, RequirementSet.trivial())

    # Assert
    assert landing_site.connections[source][target] == RequirementSet.trivial()
예제 #12
0
    def open_data_visualizer_at(
        self,
        world_name: Optional[str],
        area_name: Optional[str],
        game: RandovaniaGame = RandovaniaGame.PRIME2,
    ):
        self._data_visualizer = DataEditorWindow.open_internal_data(
            game, False)

        if world_name is not None:
            self._data_visualizer.focus_on_world(world_name)

        if area_name is not None:
            self._data_visualizer.focus_on_area(area_name)

        self._data_visualizer.show()
예제 #13
0
    def open_data_visualizer_at(
        self,
        world_name: Optional[str],
        area_name: Optional[str],
        game: RandovaniaGame = RandovaniaGame.METROID_PRIME_ECHOES,
    ):
        from randovania.gui.data_editor import DataEditorWindow
        data_visualizer = DataEditorWindow.open_internal_data(game, False)
        self._data_visualizer = data_visualizer

        if world_name is not None:
            data_visualizer.focus_on_world_by_name(world_name)

        if area_name is not None:
            data_visualizer.focus_on_area_by_name(area_name)

        self._data_visualizer.show()
예제 #14
0
def test_select_area_by_name(
    echoes_game_data,
    skip_qtbot,
):
    # Setup
    window = DataEditorWindow(echoes_game_data, None, False, True)
    skip_qtbot.addWidget(window)

    # Run
    window.focus_on_world_by_name("Torvus Bog")

    assert window.current_area.name != "Forgotten Bridge"
    window.focus_on_area_by_name("Forgotten Bridge")

    # Assert
    assert window.current_area.name == "Forgotten Bridge"
예제 #15
0
def test_create_node_and_save(tmpdir, echoes_game_data, skip_qtbot):
    # Setup
    db_path = Path(tmpdir.join("game.json"))

    window = DataEditorWindow(echoes_game_data, db_path, True, True)
    skip_qtbot.addWidget(window)

    # Run
    window._do_create_node("Some Node")
    window._save_as_internal_database()

    # Assert
    with db_path.open() as data_file:
        exported_data = json.load(data_file)
    exported_game = data_reader.decode_data(exported_data)

    output = io.StringIO()
    randovania.game_description.pretty_print.write_human_readable_world_list(
        exported_game, output)

    assert Path(
        tmpdir.join("game.txt")).read_text("utf-8") == output.getvalue()
예제 #16
0
def test_on_filters_changed_view_mode(tmp_path, mocker, skip_qtbot):
    db_path = Path(tmp_path.joinpath("test-game", "game"))
    game_data = default_data.read_json_then_binary(RandovaniaGame.BLANK)[1]

    window = DataEditorWindow(game_data, db_path, True, False)
    skip_qtbot.addWidget(window)

    # Disable the layer
    found = False
    for (trick,
         trick_check), trick_combo in window.layers_editor.tricks.items():
        if trick.long_name == "Combat":
            trick_check.setChecked(True)
            trick_combo.setCurrentIndex(1)
            found = True

    assert found
    window.layers_editor.layer_checks[1].setChecked(False)

    window.focus_on_world_by_name("Intro")
    window.focus_on_area_by_name("Boss Arena")

    assert window.current_area.node_with_name("Pickup (Free Loot)") is None
예제 #17
0
class MainWindow(QMainWindow, Ui_MainWindow, TabService, BackgroundTaskMixin):
    newer_version_signal = Signal(str, str)
    options_changed_signal = Signal()
    is_preview_mode: bool = False

    menu_new_version: Optional[QAction] = None
    _current_version_url: Optional[str] = None
    _options: Options

    @property
    def _tab_widget(self):
        return self.tabWidget

    def __init__(self, options: Options, preview: bool):
        super().__init__()
        self.setupUi(self)
        self.setWindowTitle("Randovania {}".format(VERSION))
        self.is_preview_mode = preview
        self.setAcceptDrops(True)
        set_default_window_icon(self)

        if preview:
            debug._DEBUG_LEVEL = 2

        # Signals
        self.newer_version_signal.connect(self.display_new_version)
        self.background_tasks_button_lock_signal.connect(
            self.enable_buttons_with_background_tasks)
        self.progress_update_signal.connect(self.update_progress)
        self.stop_background_process_button.clicked.connect(
            self.stop_background_process)
        self.options_changed_signal.connect(self.on_options_changed)

        # Menu Bar
        self.menu_action_data_visualizer.triggered.connect(
            self._open_data_visualizer)
        self.menu_action_existing_seed_details.triggered.connect(
            self._open_existing_seed_details)
        self.menu_action_tracker.triggered.connect(self._open_tracker)
        self.menu_action_edit_new_database.triggered.connect(
            self._open_data_editor_default)
        self.menu_action_edit_existing_database.triggered.connect(
            self._open_data_editor_prompt)

        _translate = QtCore.QCoreApplication.translate
        self.tabs = []

        self.tab_windows = [
            (ISOManagementWindow, "ROM Settings"),
            (LogicSettingsWindow, "Logic Settings"),
            (ItemQuantitiesWindow, "Item Quantities"),
        ]

        for i, tab in enumerate(self.tab_windows):
            self.windows.append(tab[0](self, self, options))
            self.tabs.append(self.windows[i].centralWidget)
            self.tabWidget.insertTab(i, self.tabs[i],
                                     _translate("MainWindow", tab[1]))

        # Setting this event only now, so all options changed trigger only once
        options.on_options_changed = self.options_changed_signal.emit
        self._options = options
        self.on_options_changed()

        self.tabWidget.setCurrentIndex(0)
        get_latest_version(self.newer_version_signal.emit)

    def closeEvent(self, event):
        self.stop_background_process()
        for window in self.windows:
            window.closeEvent(event)
        super().closeEvent(event)

    def dragEnterEvent(self, event):
        for url in event.mimeData().urls():
            if os.path.splitext(url.toLocalFile())[1] == ".iso":
                event.acceptProposedAction()
                return

    def dropEvent(self, event):
        for url in event.mimeData().urls():
            iso_path = url.toLocalFile()
            if os.path.splitext(iso_path)[1] == ".iso":
                self.get_tab(ISOManagementWindow).load_game(Path(iso_path))
                return

    def display_new_version(self, new_version: str, new_version_url: str):
        if self.menu_new_version is None:
            self.menu_new_version = QAction("", self)
            self.menu_new_version.triggered.connect(self.open_version_link)
            self.menu_bar.addAction(self.menu_new_version)

        self.menu_new_version.setText(
            "New version available: {}".format(new_version))
        self._current_version_url = new_version_url

    def open_version_link(self):
        if self._current_version_url is None:
            raise RuntimeError(
                "Called open_version_link, but _current_version_url is None")

        QDesktopServices.openUrl(QUrl(self._current_version_url))

    # Options
    def on_options_changed(self):
        for window in self.windows:
            window.on_options_changed()

    # Menu Actions
    def _open_data_visualizer(self):
        self._data_visualizer = DataEditorWindow(
            default_data.decode_default_prime2(), False)
        self._data_visualizer.show()

    def _open_data_editor_default(self):
        self._data_editor = DataEditorWindow(
            default_data.decode_default_prime2(), True)
        self._data_editor.show()

    def _open_data_editor_prompt(self):
        database_path = prompt_user_for_database_file(self)
        if database_path is None:
            return

        with database_path.open("r") as database_file:
            self._data_editor = DataEditorWindow(json.load(database_file),
                                                 True)
            self._data_editor.show()

    def _open_existing_seed_details(self):
        json_path = prompt_user_for_seed_log(self)
        if json_path is None:
            return

        self._seed_details = SeedDetailsWindow(json_path)
        self._seed_details.show()

    def _open_tracker(self):
        self._tracker = TrackerWindow(self._options.layout_configuration)
        self._tracker.show()

    # Background Process

    def enable_buttons_with_background_tasks(self, value: bool):
        self.stop_background_process_button.setEnabled(not value)

    def update_progress(self, message: str, percentage: int):
        self.progress_label.setText(message)
        if "Aborted" in message:
            percentage = 0
        if percentage >= 0:
            self.progress_bar.setRange(0, 100)
            self.progress_bar.setValue(percentage)
        else:
            self.progress_bar.setRange(0, 0)
예제 #18
0
class MainWindow(WindowManager, Ui_MainWindow):
    options_changed_signal = Signal()
    _is_preview_mode: bool = False
    _experimental_games_visible: bool = False

    menu_new_version: Optional[QtGui.QAction] = None
    _current_version_url: Optional[str] = None
    _options: Options
    _data_visualizer: Optional[QtWidgets.QWidget] = None
    _map_tracker: QtWidgets.QWidget
    _preset_manager: PresetManager
    GameDetailsSignal = Signal(LayoutDescription)

    InitPostShowSignal = Signal()

    @property
    def _tab_widget(self):
        return self.main_tab_widget

    @property
    def preset_manager(self) -> PresetManager:
        return self._preset_manager

    @property
    def main_window(self) -> QtWidgets.QMainWindow:
        return self

    @property
    def is_preview_mode(self) -> bool:
        return self._is_preview_mode

    def __init__(self, options: Options, preset_manager: PresetManager,
                 network_client, preview: bool):
        super().__init__()
        self.setupUi(self)
        self.setWindowTitle("Randovania {}".format(VERSION))
        self._is_preview_mode = preview
        self.setAcceptDrops(True)
        common_qt_lib.set_default_window_icon(self)

        self.setup_about_text()
        self.setup_welcome_text()
        self.browse_racetime_label.setText(
            self.browse_racetime_label.text().replace("color:#0000ff;", ""))

        self._preset_manager = preset_manager
        self.network_client = network_client

        if preview:
            debug.set_level(2)

        if randovania.is_frozen():
            self.menu_bar.removeAction(self.menu_edit.menuAction())

        # Signals
        self.options_changed_signal.connect(self.on_options_changed)
        self.GameDetailsSignal.connect(self._open_game_details)
        self.InitPostShowSignal.connect(self.initialize_post_show)

        self.intro_play_now_button.clicked.connect(
            lambda: self.main_tab_widget.setCurrentWidget(self.tab_play))
        self.open_faq_button.clicked.connect(self._open_faq)
        self.open_database_viewer_button.clicked.connect(
            partial(self._open_data_visualizer_for_game,
                    RandovaniaGame.METROID_PRIME_ECHOES))

        self.import_permalink_button.clicked.connect(self._import_permalink)
        self.import_game_file_button.clicked.connect(self._import_spoiler_log)
        self.browse_racetime_button.clicked.connect(self._browse_racetime)
        self.create_new_seed_button.clicked.connect(
            lambda: self.main_tab_widget.setCurrentWidget(self.tab_create_seed
                                                          ))

        # Menu Bar
        self.game_menus = []
        self.menu_action_edits = []

        for game in RandovaniaGame.sorted_all_games():
            # Sub-Menu in Open Menu
            game_menu = QtWidgets.QMenu(self.menu_open)
            game_menu.setTitle(_t(game.long_name))
            game_menu.game = game

            if game.data.development_state.can_view(False):
                self.menu_open.addAction(game_menu.menuAction())
            self.game_menus.append(game_menu)

            game_trick_details_menu = QtWidgets.QMenu(game_menu)
            game_trick_details_menu.setTitle(_t("Trick Details"))
            self._setup_trick_difficulties_menu_on_show(
                game_trick_details_menu, game)

            game_data_visualizer_action = QtGui.QAction(game_menu)
            game_data_visualizer_action.setText(_t("Data Visualizer"))
            game_data_visualizer_action.triggered.connect(
                partial(self._open_data_visualizer_for_game, game))

            game_menu.addAction(game_trick_details_menu.menuAction())
            game_menu.addAction(game_data_visualizer_action)

            # Data Editor
            action = QtGui.QAction(self)
            action.setText(_t(game.long_name))
            self.menu_internal.addAction(action)
            action.triggered.connect(
                partial(self._open_data_editor_for_game, game))
            self.menu_action_edits.append(action)

        self.menu_action_edit_existing_database.triggered.connect(
            self._open_data_editor_prompt)
        self.menu_action_validate_seed_after.triggered.connect(
            self._on_validate_seed_change)
        self.menu_action_timeout_generation_after_a_time_limit.triggered.connect(
            self._on_generate_time_limit_change)
        self.menu_action_dark_mode.triggered.connect(
            self._on_menu_action_dark_mode)
        self.menu_action_experimental_games.triggered.connect(
            self._on_menu_action_experimental_games)
        self.menu_action_open_auto_tracker.triggered.connect(
            self._open_auto_tracker)
        self.menu_action_previously_generated_games.triggered.connect(
            self._on_menu_action_previously_generated_games)
        self.menu_action_log_files_directory.triggered.connect(
            self._on_menu_action_log_files_directory)
        self.menu_action_layout_editor.triggered.connect(
            self._on_menu_action_layout_editor)

        # Setting this event only now, so all options changed trigger only once
        options.on_options_changed = self.options_changed_signal.emit
        self._options = options

        self.main_tab_widget.setCurrentIndex(0)

    def closeEvent(self, event):
        self.generate_seed_tab.stop_background_process()
        super().closeEvent(event)

    def dragEnterEvent(self, event: QtGui.QDragEnterEvent):
        from randovania.layout.versioned_preset import VersionedPreset

        valid_extensions = [
            LayoutDescription.file_extension(),
            VersionedPreset.file_extension(),
        ]
        valid_extensions_with_dot = {
            f".{extension}"
            for extension in valid_extensions
        }

        for url in event.mimeData().urls():
            ext = os.path.splitext(url.toLocalFile())[1]
            if ext in valid_extensions_with_dot:
                event.acceptProposedAction()
                return

    def dropEvent(self, event: QtGui.QDropEvent):
        from randovania.layout.versioned_preset import VersionedPreset

        for url in event.mimeData().urls():
            path = Path(url.toLocalFile())
            if path.suffix == f".{LayoutDescription.file_extension()}":
                self.open_game_details(LayoutDescription.from_file(path))
                return

            elif path.suffix == f".{VersionedPreset.file_extension()}":
                self.main_tab_widget.setCurrentWidget(self.tab_create_seed)
                self.generate_seed_tab.import_preset_file(path)
                return

    def showEvent(self, event: QtGui.QShowEvent):
        self.InitPostShowSignal.emit()

    # Per-Game elements
    def refresh_game_list(self):
        self.games_tab.set_experimental_visible(
            self.menu_action_experimental_games.isChecked())

        if self._experimental_games_visible == self.menu_action_experimental_games.isChecked(
        ):
            return
        self._experimental_games_visible = self.menu_action_experimental_games.isChecked(
        )

        for game_menu in self.game_menus:
            self.menu_open.removeAction(game_menu.menuAction())

        for game_menu, edit_action in zip(self.game_menus,
                                          self.menu_action_edits):
            game: RandovaniaGame = game_menu.game
            if game.data.development_state.can_view(
                    self.menu_action_experimental_games.isChecked()):
                self.menu_open.addAction(game_menu.menuAction())

    # Delayed Initialization
    @asyncSlot()
    async def initialize_post_show(self):
        self.InitPostShowSignal.disconnect(self.initialize_post_show)
        logging.info("Will initialize things in post show")
        await self._initialize_post_show_body()
        logging.info("Finished initializing post show")

    async def _initialize_post_show_body(self):
        logging.info("Will load OnlineInteractions")
        from randovania.gui.main_online_interaction import OnlineInteractions
        logging.info("Creating OnlineInteractions...")
        self.online_interactions = OnlineInteractions(self,
                                                      self.preset_manager,
                                                      self.network_client,
                                                      self, self._options)

        logging.info("Will load GenerateSeedTab")
        from randovania.gui.generate_seed_tab import GenerateSeedTab
        logging.info("Creating GenerateSeedTab...")
        self.generate_seed_tab = GenerateSeedTab(self, self, self._options)
        logging.info("Running GenerateSeedTab.setup_ui")
        self.generate_seed_tab.setup_ui()

        logging.info("Will update for modified options")
        with self._options:
            self.on_options_changed()

    # Generate Seed
    def _open_faq(self):
        self.main_tab_widget.setCurrentWidget(self.games_tab)

    async def generate_seed_from_permalink(self, permalink: Permalink):
        from randovania.lib.status_update_lib import ProgressUpdateCallable
        from randovania.gui.dialog.background_process_dialog import BackgroundProcessDialog

        def work(progress_update: ProgressUpdateCallable):
            from randovania.interface_common import simplified_patcher
            layout = simplified_patcher.generate_layout(
                progress_update=progress_update,
                parameters=permalink.parameters,
                options=self._options)
            progress_update(f"Success! (Seed hash: {layout.shareable_hash})",
                            1)
            return layout

        new_layout = await BackgroundProcessDialog.open_for_background_task(
            work, "Creating a game...")

        if permalink.seed_hash is not None and permalink.seed_hash != new_layout.shareable_hash_bytes:
            response = await async_dialog.warning(
                self,
                "Unexpected hash",
                "Expected has to be {}. got {}. Do you wish to continue?".
                format(
                    base64.b32encode(permalink.seed_hash).decode(),
                    new_layout.shareable_hash,
                ),
                QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
            )
            if response != QtWidgets.QMessageBox.Yes:
                return

        self.open_game_details(new_layout)

    @asyncSlot()
    async def _import_permalink(self):
        from randovania.gui.dialog.permalink_dialog import PermalinkDialog
        dialog = PermalinkDialog()
        result = await async_dialog.execute_dialog(dialog)
        if result == QtWidgets.QDialog.Accepted:
            permalink = dialog.get_permalink_from_field()
            await self.generate_seed_from_permalink(permalink)

    def _import_spoiler_log(self):
        json_path = common_qt_lib.prompt_user_for_input_game_log(self)
        if json_path is not None:
            layout = LayoutDescription.from_file(json_path)
            self.open_game_details(layout)

    @asyncSlot()
    async def _browse_racetime(self):
        from randovania.gui.dialog.racetime_browser_dialog import RacetimeBrowserDialog
        dialog = RacetimeBrowserDialog()
        if not await dialog.refresh():
            return
        result = await async_dialog.execute_dialog(dialog)
        if result == QtWidgets.QDialog.Accepted:
            await self.generate_seed_from_permalink(dialog.permalink)

    def open_game_details(self, layout: LayoutDescription):
        self.GameDetailsSignal.emit(layout)

    def _open_game_details(self, layout: LayoutDescription):
        from randovania.gui.game_details.game_details_window import GameDetailsWindow
        details_window = GameDetailsWindow(self, self._options)
        details_window.update_layout_description(layout)
        details_window.show()
        self.track_window(details_window)

    # Releases info
    async def request_new_data(self):
        from randovania.interface_common import github_releases_data
        await self._on_releases_data(await github_releases_data.get_releases())

    async def _on_releases_data(self, releases: Optional[List[dict]]):
        current_version = update_checker.strict_current_version()
        last_changelog = self._options.last_changelog_displayed

        all_change_logs, new_change_logs, version_to_display = update_checker.versions_to_display_for_releases(
            current_version, last_changelog, releases)

        if version_to_display is not None:
            self.display_new_version(version_to_display)

        if all_change_logs:
            from randovania.gui.widgets.changelog_widget import ChangeLogWidget
            changelog_tab = ChangeLogWidget(all_change_logs)
            self.main_tab_widget.addTab(changelog_tab, "Change Log")

        if new_change_logs:
            from randovania.gui.lib.scroll_message_box import ScrollMessageBox

            message_box = ScrollMessageBox.create_new(
                self,
                QtWidgets.QMessageBox.Information,
                "What's new",
                "\n".join(new_change_logs),
                QtWidgets.QMessageBox.Ok,
            )
            message_box.label.setTextFormat(QtCore.Qt.TextFormat.MarkdownText)
            message_box.scroll_area.setMinimumSize(500, 300)
            await async_dialog.execute_dialog(message_box)

            with self._options as options:
                options.last_changelog_displayed = current_version

    def display_new_version(self, version: update_checker.VersionDescription):
        if self.menu_new_version is None:
            self.menu_new_version = QtGui.QAction("", self)
            self.menu_new_version.triggered.connect(self.open_version_link)
            self.menu_bar.addAction(self.menu_new_version)

        self.menu_new_version.setText("New version available: {}".format(
            version.tag_name))
        self._current_version_url = version.html_url

    def open_version_link(self):
        if self._current_version_url is None:
            raise RuntimeError(
                "Called open_version_link, but _current_version_url is None")

        QtGui.QDesktopServices.openUrl(QUrl(self._current_version_url))

    # Options
    def on_options_changed(self):
        self.menu_action_validate_seed_after.setChecked(
            self._options.advanced_validate_seed_after)
        self.menu_action_timeout_generation_after_a_time_limit.setChecked(
            self._options.advanced_timeout_during_generation)
        self.menu_action_dark_mode.setChecked(self._options.dark_mode)

        self.menu_action_experimental_games.setChecked(
            self._options.experimental_games)
        self.refresh_game_list()

        self.generate_seed_tab.on_options_changed(self._options)
        theme.set_dark_theme(self._options.dark_mode)

    # Menu Actions
    def _open_data_visualizer_for_game(self, game: RandovaniaGame):
        self.open_data_visualizer_at(None, None, game)

    def open_data_visualizer_at(
        self,
        world_name: Optional[str],
        area_name: Optional[str],
        game: RandovaniaGame = RandovaniaGame.METROID_PRIME_ECHOES,
    ):
        from randovania.gui.data_editor import DataEditorWindow
        data_visualizer = DataEditorWindow.open_internal_data(game, False)
        self._data_visualizer = data_visualizer

        if world_name is not None:
            data_visualizer.focus_on_world_by_name(world_name)

        if area_name is not None:
            data_visualizer.focus_on_area_by_name(area_name)

        self._data_visualizer.show()

    def _open_data_editor_for_game(self, game: RandovaniaGame):
        from randovania.gui.data_editor import DataEditorWindow
        self._data_editor = DataEditorWindow.open_internal_data(game, True)
        self._data_editor.show()

    def _open_data_editor_prompt(self):
        from randovania.gui.data_editor import DataEditorWindow

        database_path = common_qt_lib.prompt_user_for_database_file(self)
        if database_path is None:
            return

        with database_path.open("r") as database_file:
            self._data_editor = DataEditorWindow(json.load(database_file),
                                                 database_path, False, True)
            self._data_editor.show()

    async def open_map_tracker(self, configuration: "Preset"):
        from randovania.gui.tracker_window import TrackerWindow, InvalidLayoutForTracker

        try:
            self._map_tracker = await TrackerWindow.create_new(
                self._options.tracker_files_path, configuration)
        except InvalidLayoutForTracker as e:
            QtWidgets.QMessageBox.critical(
                self, "Unsupported configuration for Tracker", str(e))
            return

        self._map_tracker.show()

    # Difficulties stuff

    def _exec_trick_details(self, popup: "TrickDetailsPopup"):
        self._trick_details_popup = popup
        self._trick_details_popup.setWindowModality(Qt.WindowModal)
        self._trick_details_popup.open()

    def _open_trick_details_popup(self, game, trick: TrickResourceInfo,
                                  level: LayoutTrickLevel):
        from randovania.gui.dialog.trick_details_popup import TrickDetailsPopup
        self._exec_trick_details(
            TrickDetailsPopup(self, self, game, trick, level))

    def _setup_trick_difficulties_menu_on_show(self, menu: QtWidgets.QMenu,
                                               game: RandovaniaGame):
        def on_show():
            menu.aboutToShow.disconnect(on_show)
            self._setup_difficulties_menu(game, menu)

        menu.aboutToShow.connect(on_show)

    def _setup_difficulties_menu(self, game: RandovaniaGame,
                                 menu: QtWidgets.QMenu):
        from randovania.game_description import default_database
        game = default_database.game_description_for(game)
        tricks_in_use = used_tricks(game)

        menu.clear()
        for trick in sorted(game.resource_database.trick,
                            key=lambda _trick: _trick.long_name):
            if trick not in tricks_in_use:
                continue

            trick_menu = QtWidgets.QMenu(self)
            trick_menu.setTitle(_t(trick.long_name))
            menu.addAction(trick_menu.menuAction())

            used_difficulties = difficulties_for_trick(game, trick)
            for trick_level in enum_lib.iterate_enum(LayoutTrickLevel):
                if trick_level in used_difficulties:
                    difficulty_action = QtGui.QAction(self)
                    difficulty_action.setText(trick_level.long_name)
                    trick_menu.addAction(difficulty_action)
                    difficulty_action.triggered.connect(
                        functools.partial(self._open_trick_details_popup, game,
                                          trick, trick_level))

    # ==========

    @asyncSlot()
    async def _on_validate_seed_change(self):
        old_value = self._options.advanced_validate_seed_after
        new_value = self.menu_action_validate_seed_after.isChecked()

        if old_value and not new_value:
            box = QtWidgets.QMessageBox(self)
            box.setWindowTitle("Disable validation?")
            box.setText(_DISABLE_VALIDATION_WARNING)
            box.setIcon(QtWidgets.QMessageBox.Warning)
            box.setStandardButtons(QtWidgets.QMessageBox.Yes
                                   | QtWidgets.QMessageBox.No)
            box.setDefaultButton(QtWidgets.QMessageBox.No)
            user_response = await async_dialog.execute_dialog(box)
            if user_response != QtWidgets.QMessageBox.Yes:
                self.menu_action_validate_seed_after.setChecked(True)
                return

        with self._options as options:
            options.advanced_validate_seed_after = new_value

    def _on_generate_time_limit_change(self):
        is_checked = self.menu_action_timeout_generation_after_a_time_limit.isChecked(
        )
        with self._options as options:
            options.advanced_timeout_during_generation = is_checked

    def _on_menu_action_dark_mode(self):
        with self._options as options:
            options.dark_mode = self.menu_action_dark_mode.isChecked()

    def _on_menu_action_experimental_games(self):
        with self._options as options:
            options.experimental_games = self.menu_action_experimental_games.isChecked(
            )

    def _open_auto_tracker(self):
        from randovania.gui.auto_tracker_window import AutoTrackerWindow
        self.auto_tracker_window = AutoTrackerWindow(
            common_qt_lib.get_game_connection(), self._options)
        self.auto_tracker_window.show()

    def _on_menu_action_previously_generated_games(self):
        path = self._options.game_history_path
        try:
            open_directory_in_explorer(path)

        except OSError:
            box = QtWidgets.QMessageBox(
                QtWidgets.QMessageBox.Information, "Game History",
                f"Previously generated games can be found at:\n{path}",
                QtWidgets.QMessageBox.Ok, self)
            box.setTextInteractionFlags(Qt.TextSelectableByMouse)
            box.show()

    def _on_menu_action_log_files_directory(self):
        path = self._options.logs_path
        try:
            open_directory_in_explorer(path)

        except OSError:
            box = QtWidgets.QMessageBox(
                QtWidgets.QMessageBox.Information, "Logs",
                f"Randovania logs can be found at:\n{path}",
                QtWidgets.QMessageBox.Ok, self)
            box.setTextInteractionFlags(Qt.TextSelectableByMouse)
            box.show()

    def _on_menu_action_layout_editor(self):
        from randovania.gui.corruption_layout_editor import CorruptionLayoutEditor
        self.corruption_editor = CorruptionLayoutEditor()
        self.corruption_editor.show()

    def setup_about_text(self):
        ABOUT_TEXT = "\n".join([
            "# Randovania",
            "",
            "<https://github.com/randovania/randovania>",
            "",
            "This software is covered by the [GNU General Public License v3 (GPLv3)](https://www.gnu.org/licenses/gpl-3.0.en.html)",
            "",
            "{community}",
            "",
            "{credits}",
        ])

        about_document: QtGui.QTextDocument = self.about_text_browser.document(
        )

        # Populate from README.md
        community = get_readme_section("COMMUNITY")
        credit = get_readme_section("CREDITS")
        about_document.setMarkdown(
            ABOUT_TEXT.format(community=community, credits=credit))

        # Remove all hardcoded link color
        about_document.setHtml(about_document.toHtml().replace(
            "color:#0000ff;", ""))
        cursor: QtGui.QTextCursor = self.about_text_browser.textCursor()
        cursor.setPosition(0)
        self.about_text_browser.setTextCursor(cursor)

    def setup_welcome_text(self):
        self.intro_label.setText(
            self.intro_label.text().format(version=VERSION))

        welcome = get_readme_section("WELCOME")
        supported = get_readme_section("SUPPORTED")
        experimental = get_readme_section("EXPERIMENTAL")

        self.games_supported_label.setText(supported)
        self.games_experimental_label.setText(experimental)
        self.intro_welcome_label.setText(welcome)
예제 #19
0
 def _open_data_editor_for_game(self, game: RandovaniaGame):
     from randovania.gui.data_editor import DataEditorWindow
     self._data_editor = DataEditorWindow.open_internal_data(game, True)
     self._data_editor.show()
예제 #20
0
class MainWindow(WindowManager, Ui_MainWindow):
    newer_version_signal = Signal(str, str)
    options_changed_signal = Signal()
    _is_preview_mode: bool = False

    menu_new_version: Optional[QtWidgets.QAction] = None
    _current_version_url: Optional[str] = None
    _options: Options
    _data_visualizer: Optional[QtWidgets.QWidget] = None
    _map_tracker: QtWidgets.QWidget
    _preset_manager: PresetManager
    GameDetailsSignal = Signal(LayoutDescription)

    InitPostShowSignal = Signal()

    @property
    def _tab_widget(self):
        return self.main_tab_widget

    @property
    def preset_manager(self) -> PresetManager:
        return self._preset_manager

    @property
    def main_window(self) -> QtWidgets.QMainWindow:
        return self

    @property
    def is_preview_mode(self) -> bool:
        return self._is_preview_mode

    def __init__(self, options: Options, preset_manager: PresetManager,
                 network_client, preview: bool):
        super().__init__()
        self.setupUi(self)
        self.setWindowTitle("Randovania {}".format(VERSION))
        self._is_preview_mode = preview
        self.setAcceptDrops(True)
        common_qt_lib.set_default_window_icon(self)

        # Remove all hardcoded link color
        about_document: QtGui.QTextDocument = self.about_text_browser.document(
        )
        about_document.setHtml(about_document.toHtml().replace(
            "color:#0000ff;", ""))
        self.browse_racetime_label.setText(
            self.browse_racetime_label.text().replace("color:#0000ff;", ""))

        self.intro_label.setText(
            self.intro_label.text().format(version=VERSION))

        self._preset_manager = preset_manager
        self.network_client = network_client

        if preview:
            debug.set_level(2)

        # Signals
        self.newer_version_signal.connect(self.display_new_version)
        self.options_changed_signal.connect(self.on_options_changed)
        self.GameDetailsSignal.connect(self._open_game_details)
        self.InitPostShowSignal.connect(self.initialize_post_show)

        self.intro_play_now_button.clicked.connect(
            lambda: self.welcome_tab_widget.setCurrentWidget(self.tab_play))
        self.open_faq_button.clicked.connect(self._open_faq)
        self.open_database_viewer_button.clicked.connect(
            partial(self._open_data_visualizer_for_game,
                    RandovaniaGame.PRIME2))

        for game in RandovaniaGame:
            self.hint_item_names_game_combo.addItem(game.long_name, game)
            self.hint_location_game_combo.addItem(game.long_name, game)

        self.hint_item_names_game_combo.currentIndexChanged.connect(
            self._update_hints_text)
        self.hint_location_game_combo.currentIndexChanged.connect(
            self._update_hint_locations)

        self.import_permalink_button.clicked.connect(self._import_permalink)
        self.import_game_file_button.clicked.connect(self._import_spoiler_log)
        self.browse_racetime_button.clicked.connect(self._browse_racetime)
        self.create_new_seed_button.clicked.connect(
            lambda: self.welcome_tab_widget.setCurrentWidget(self.
                                                             tab_create_seed))

        # Menu Bar
        for action, game in ((self.menu_action_prime_1_data_visualizer,
                              RandovaniaGame.PRIME1),
                             (self.menu_action_prime_2_data_visualizer,
                              RandovaniaGame.PRIME2),
                             (self.menu_action_prime_3_data_visualizer,
                              RandovaniaGame.PRIME3)):
            action.triggered.connect(
                partial(self._open_data_visualizer_for_game, game))

        for action, game in ((self.menu_action_edit_prime_1,
                              RandovaniaGame.PRIME1),
                             (self.menu_action_edit_prime_2,
                              RandovaniaGame.PRIME2),
                             (self.menu_action_edit_prime_3,
                              RandovaniaGame.PRIME3)):
            action.triggered.connect(
                partial(self._open_data_editor_for_game, game))

        self.menu_action_item_tracker.triggered.connect(
            self._open_item_tracker)
        self.menu_action_map_tracker.triggered.connect(
            self._on_menu_action_map_tracker)
        self.menu_action_edit_existing_database.triggered.connect(
            self._open_data_editor_prompt)
        self.menu_action_validate_seed_after.triggered.connect(
            self._on_validate_seed_change)
        self.menu_action_timeout_generation_after_a_time_limit.triggered.connect(
            self._on_generate_time_limit_change)
        self.menu_action_dark_mode.triggered.connect(
            self._on_menu_action_dark_mode)
        self.menu_action_open_auto_tracker.triggered.connect(
            self._open_auto_tracker)
        self.menu_action_previously_generated_games.triggered.connect(
            self._on_menu_action_previously_generated_games)
        self.menu_action_layout_editor.triggered.connect(
            self._on_menu_action_layout_editor)

        self.menu_prime_1_trick_details.aboutToShow.connect(
            self._create_trick_details_prime_1)
        self.menu_prime_2_trick_details.aboutToShow.connect(
            self._create_trick_details_prime_2)
        self.menu_prime_3_trick_details.aboutToShow.connect(
            self._create_trick_details_prime_3)

        # Setting this event only now, so all options changed trigger only once
        options.on_options_changed = self.options_changed_signal.emit
        self._options = options

        self.main_tab_widget.setCurrentIndex(0)

    def closeEvent(self, event):
        self.generate_seed_tab.stop_background_process()
        super().closeEvent(event)

    def dragEnterEvent(self, event: QtGui.QDragEnterEvent):
        from randovania.layout.preset_migration import VersionedPreset

        valid_extensions = [
            LayoutDescription.file_extension(),
            VersionedPreset.file_extension(),
        ]
        valid_extensions_with_dot = {
            f".{extension}"
            for extension in valid_extensions
        }

        for url in event.mimeData().urls():
            ext = os.path.splitext(url.toLocalFile())[1]
            if ext in valid_extensions_with_dot:
                event.acceptProposedAction()
                return

    def dropEvent(self, event: QtGui.QDropEvent):
        from randovania.layout.preset_migration import VersionedPreset

        for url in event.mimeData().urls():
            path = Path(url.toLocalFile())
            if path.suffix == f".{LayoutDescription.file_extension()}":
                self.open_game_details(LayoutDescription.from_file(path))
                return

            elif path.suffix == f".{VersionedPreset.file_extension()}":
                self.main_tab_widget.setCurrentWidget(self.welcome_tab)
                self.welcome_tab_widget.setCurrentWidget(self.tab_create_seed)
                self.generate_seed_tab.import_preset_file(path)
                return

    def showEvent(self, event: QtGui.QShowEvent):
        self.InitPostShowSignal.emit()

    # Delayed Initialization
    @asyncSlot()
    async def initialize_post_show(self):
        self.InitPostShowSignal.disconnect(self.initialize_post_show)
        logging.info("Will initialize things in post show")
        await self._initialize_post_show_body()
        logging.info("Finished initializing post show")

    async def _initialize_post_show_body(self):
        logging.info("Will load OnlineInteractions")
        from randovania.gui.main_online_interaction import OnlineInteractions
        logging.info("Creating OnlineInteractions...")
        self.online_interactions = OnlineInteractions(self,
                                                      self.preset_manager,
                                                      self.network_client,
                                                      self, self._options)

        logging.info("Will load GenerateSeedTab")
        from randovania.gui.generate_seed_tab import GenerateSeedTab
        logging.info("Creating GenerateSeedTab...")
        self.generate_seed_tab = GenerateSeedTab(self, self, self._options)
        logging.info("Running GenerateSeedTab.setup_ui")
        self.generate_seed_tab.setup_ui()

        # Update hints text
        logging.info("Will _update_hints_text")
        self._update_hints_text()
        logging.info("Will hide hint locations combo")
        self.hint_location_game_combo.setVisible(False)
        self.hint_location_game_combo.setCurrentIndex(1)

        logging.info("Will update for modified options")
        with self._options:
            self.on_options_changed()

    def _update_hints_text(self):
        from randovania.gui.lib import hints_text
        hints_text.update_hints_text(
            self.hint_item_names_game_combo.currentData(),
            self.hint_item_names_tree_widget)

    def _update_hint_locations(self):
        from randovania.gui.lib import hints_text
        hints_text.update_hint_locations(
            self.hint_location_game_combo.currentData(), self.hint_tree_widget)

    # Generate Seed
    def _open_faq(self):
        self.main_tab_widget.setCurrentWidget(self.help_tab)
        self.help_tab_widget.setCurrentWidget(self.tab_faq)

    async def generate_seed_from_permalink(self, permalink):
        from randovania.interface_common.status_update_lib import ProgressUpdateCallable
        from randovania.gui.dialog.background_process_dialog import BackgroundProcessDialog

        def work(progress_update: ProgressUpdateCallable):
            from randovania.interface_common import simplified_patcher
            layout = simplified_patcher.generate_layout(
                progress_update=progress_update,
                permalink=permalink,
                options=self._options)
            progress_update(f"Success! (Seed hash: {layout.shareable_hash})",
                            1)
            return layout

        new_layout = await BackgroundProcessDialog.open_for_background_task(
            work, "Creating a game...")
        self.open_game_details(new_layout)

    @asyncSlot()
    async def _import_permalink(self):
        from randovania.gui.dialog.permalink_dialog import PermalinkDialog
        dialog = PermalinkDialog()
        result = await async_dialog.execute_dialog(dialog)
        if result == QtWidgets.QDialog.Accepted:
            permalink = dialog.get_permalink_from_field()
            await self.generate_seed_from_permalink(permalink)

    def _import_spoiler_log(self):
        json_path = common_qt_lib.prompt_user_for_input_game_log(self)
        if json_path is not None:
            layout = LayoutDescription.from_file(json_path)
            self.open_game_details(layout)

    @asyncSlot()
    async def _browse_racetime(self):
        from randovania.gui.dialog.racetime_browser_dialog import RacetimeBrowserDialog
        dialog = RacetimeBrowserDialog()
        if not await dialog.refresh():
            return
        result = await async_dialog.execute_dialog(dialog)
        if result == QtWidgets.QDialog.Accepted:
            await self.generate_seed_from_permalink(dialog.permalink)

    def open_game_details(self, layout: LayoutDescription):
        self.GameDetailsSignal.emit(layout)

    def _open_game_details(self, layout: LayoutDescription):
        from randovania.gui.seed_details_window import SeedDetailsWindow
        details_window = SeedDetailsWindow(self, self._options)
        details_window.update_layout_description(layout)
        details_window.show()
        self.track_window(details_window)

    # Releases info
    async def request_new_data(self):
        from randovania.interface_common import github_releases_data
        await self._on_releases_data(await github_releases_data.get_releases())

    async def _on_releases_data(self, releases: Optional[List[dict]]):
        import markdown

        current_version = update_checker.strict_current_version()
        last_changelog = self._options.last_changelog_displayed

        all_change_logs, new_change_logs, version_to_display = update_checker.versions_to_display_for_releases(
            current_version, last_changelog, releases)

        if version_to_display is not None:
            self.display_new_version(version_to_display)

        if all_change_logs:
            changelog_tab = QtWidgets.QWidget()
            changelog_tab.setObjectName("changelog_tab")
            changelog_tab_layout = QtWidgets.QVBoxLayout(changelog_tab)
            changelog_tab_layout.setContentsMargins(0, 0, 0, 0)
            changelog_tab_layout.setObjectName("changelog_tab_layout")
            changelog_scroll_area = QtWidgets.QScrollArea(changelog_tab)
            changelog_scroll_area.setWidgetResizable(True)
            changelog_scroll_area.setObjectName("changelog_scroll_area")
            changelog_scroll_contents = QtWidgets.QWidget()
            changelog_scroll_contents.setGeometry(QtCore.QRect(0, 0, 489, 337))
            changelog_scroll_contents.setObjectName(
                "changelog_scroll_contents")
            changelog_scroll_layout = QtWidgets.QVBoxLayout(
                changelog_scroll_contents)
            changelog_scroll_layout.setObjectName("changelog_scroll_layout")

            for entry in all_change_logs:
                changelog_label = QtWidgets.QLabel(changelog_scroll_contents)
                _update_label_on_show(changelog_label,
                                      markdown.markdown(entry))
                changelog_label.setObjectName("changelog_label")
                changelog_label.setWordWrap(True)
                changelog_scroll_layout.addWidget(changelog_label)

            changelog_scroll_area.setWidget(changelog_scroll_contents)
            changelog_tab_layout.addWidget(changelog_scroll_area)
            self.help_tab_widget.addTab(changelog_tab, "Change Log")

        if new_change_logs:
            await async_dialog.message_box(
                self, QtWidgets.QMessageBox.Information, "What's new",
                markdown.markdown("\n".join(new_change_logs)))
            with self._options as options:
                options.last_changelog_displayed = current_version

    def display_new_version(self, version: update_checker.VersionDescription):
        if self.menu_new_version is None:
            self.menu_new_version = QtWidgets.QAction("", self)
            self.menu_new_version.triggered.connect(self.open_version_link)
            self.menu_bar.addAction(self.menu_new_version)

        self.menu_new_version.setText("New version available: {}".format(
            version.tag_name))
        self._current_version_url = version.html_url

    def open_version_link(self):
        if self._current_version_url is None:
            raise RuntimeError(
                "Called open_version_link, but _current_version_url is None")

        QtGui.QDesktopServices.openUrl(QUrl(self._current_version_url))

    # Options
    def on_options_changed(self):
        self.menu_action_validate_seed_after.setChecked(
            self._options.advanced_validate_seed_after)
        self.menu_action_timeout_generation_after_a_time_limit.setChecked(
            self._options.advanced_timeout_during_generation)
        self.menu_action_dark_mode.setChecked(self._options.dark_mode)

        self.generate_seed_tab.on_options_changed(self._options)
        theme.set_dark_theme(self._options.dark_mode)

    # Menu Actions
    def _open_data_visualizer_for_game(self, game: RandovaniaGame):
        self.open_data_visualizer_at(None, None, game)

    def open_data_visualizer_at(
        self,
        world_name: Optional[str],
        area_name: Optional[str],
        game: RandovaniaGame = RandovaniaGame.PRIME2,
    ):
        from randovania.gui.data_editor import DataEditorWindow
        data_visualizer = DataEditorWindow.open_internal_data(game, False)
        self._data_visualizer = data_visualizer

        if world_name is not None:
            data_visualizer.focus_on_world(world_name)

        if area_name is not None:
            data_visualizer.focus_on_area(area_name)

        self._data_visualizer.show()

    def _open_data_editor_for_game(self, game: RandovaniaGame):
        from randovania.gui.data_editor import DataEditorWindow
        self._data_editor = DataEditorWindow.open_internal_data(game, True)
        self._data_editor.show()

    def _open_data_editor_prompt(self):
        from randovania.gui.data_editor import DataEditorWindow

        database_path = common_qt_lib.prompt_user_for_database_file(self)
        if database_path is None:
            return

        with database_path.open("r") as database_file:
            self._data_editor = DataEditorWindow(json.load(database_file),
                                                 database_path, False, True)
            self._data_editor.show()

    @asyncSlot()
    async def _on_menu_action_map_tracker(self):
        dialog = QtWidgets.QInputDialog(self)
        dialog.setWindowTitle("Map Tracker")
        dialog.setLabelText("Select preset used for the tracker.")
        dialog.setComboBoxItems(
            [preset.name for preset in self._preset_manager.all_presets])
        dialog.setTextValue(self._options.selected_preset_name)
        result = await async_dialog.execute_dialog(dialog)
        if result == QtWidgets.QDialog.Accepted:
            preset = self._preset_manager.preset_for_name(dialog.textValue())
            self.open_map_tracker(preset.get_preset().configuration)

    def open_map_tracker(self, configuration: "EchoesConfiguration"):
        from randovania.gui.tracker_window import TrackerWindow, InvalidLayoutForTracker

        try:
            self._map_tracker = TrackerWindow(self._options.tracker_files_path,
                                              configuration)
        except InvalidLayoutForTracker as e:
            QtWidgets.QMessageBox.critical(
                self, "Unsupported configuration for Tracker", str(e))
            return

        self._map_tracker.show()

    def _open_item_tracker(self):
        # Importing this at root level seems to crash linux tests :(
        from PySide2.QtWebEngineWidgets import QWebEngineView

        tracker_window = QtWidgets.QMainWindow()
        tracker_window.setWindowTitle("Item Tracker")
        tracker_window.resize(370, 380)

        web_view = QWebEngineView(tracker_window)
        tracker_window.setCentralWidget(web_view)

        self.web_view = web_view

        def update_window_icon():
            tracker_window.setWindowIcon(web_view.icon())

        web_view.iconChanged.connect(update_window_icon)
        web_view.load(
            QUrl("https://spaghettitoastbook.github.io/echoes/tracker/"))

        tracker_window.show()
        self._item_tracker_window = tracker_window

    # Difficulties stuff

    def _exec_trick_details(self, popup: "TrickDetailsPopup"):
        self._trick_details_popup = popup
        self._trick_details_popup.setWindowModality(Qt.WindowModal)
        self._trick_details_popup.open()

    def _open_trick_details_popup(self, game, trick: TrickResourceInfo,
                                  level: LayoutTrickLevel):
        from randovania.gui.dialog.trick_details_popup import TrickDetailsPopup
        self._exec_trick_details(
            TrickDetailsPopup(self, self, game, trick, level))

    def _create_trick_details_prime_1(self):
        self.menu_prime_1_trick_details.aboutToShow.disconnect(
            self._create_trick_details_prime_1)
        self._setup_difficulties_menu(RandovaniaGame.PRIME1,
                                      self.menu_prime_1_trick_details)

    def _create_trick_details_prime_2(self):
        self.menu_prime_2_trick_details.aboutToShow.disconnect(
            self._create_trick_details_prime_2)
        self._setup_difficulties_menu(RandovaniaGame.PRIME2,
                                      self.menu_prime_2_trick_details)

    def _create_trick_details_prime_3(self):
        self.menu_prime_3_trick_details.aboutToShow.disconnect(
            self._create_trick_details_prime_3)
        self._setup_difficulties_menu(RandovaniaGame.PRIME3,
                                      self.menu_prime_3_trick_details)

    def _setup_difficulties_menu(self, game: RandovaniaGame,
                                 menu: QtWidgets.QMenu):
        from randovania.game_description import default_database
        game = default_database.game_description_for(game)
        tricks_in_use = used_tricks(game)

        menu.clear()
        for trick in sorted(game.resource_database.trick,
                            key=lambda _trick: _trick.long_name):
            if trick not in tricks_in_use:
                continue

            trick_menu = QtWidgets.QMenu(self)
            trick_menu.setTitle(trick.long_name)
            menu.addAction(trick_menu.menuAction())

            used_difficulties = difficulties_for_trick(game, trick)
            for i, trick_level in enumerate(iterate_enum(LayoutTrickLevel)):
                if trick_level in used_difficulties:
                    difficulty_action = QtWidgets.QAction(self)
                    difficulty_action.setText(trick_level.long_name)
                    trick_menu.addAction(difficulty_action)
                    difficulty_action.triggered.connect(
                        functools.partial(self._open_trick_details_popup, game,
                                          trick, trick_level))

    # ==========

    @asyncSlot()
    async def _on_validate_seed_change(self):
        old_value = self._options.advanced_validate_seed_after
        new_value = self.menu_action_validate_seed_after.isChecked()

        if old_value and not new_value:
            box = QtWidgets.QMessageBox(self)
            box.setWindowTitle("Disable validation?")
            box.setText(_DISABLE_VALIDATION_WARNING)
            box.setIcon(QtWidgets.QMessageBox.Warning)
            box.setStandardButtons(QtWidgets.QMessageBox.Yes
                                   | QtWidgets.QMessageBox.No)
            box.setDefaultButton(QtWidgets.QMessageBox.No)
            user_response = await async_dialog.execute_dialog(box)
            if user_response != QtWidgets.QMessageBox.Yes:
                self.menu_action_validate_seed_after.setChecked(True)
                return

        with self._options as options:
            options.advanced_validate_seed_after = new_value

    def _on_generate_time_limit_change(self):
        is_checked = self.menu_action_timeout_generation_after_a_time_limit.isChecked(
        )
        with self._options as options:
            options.advanced_timeout_during_generation = is_checked

    def _on_menu_action_dark_mode(self):
        with self._options as options:
            options.dark_mode = self.menu_action_dark_mode.isChecked()

    def _open_auto_tracker(self):
        from randovania.gui.auto_tracker_window import AutoTrackerWindow
        self.auto_tracker_window = AutoTrackerWindow(
            common_qt_lib.get_game_connection(), self._options)
        self.auto_tracker_window.show()

    def _on_menu_action_previously_generated_games(self):
        path = self._options.data_dir.joinpath("game_history")
        try:
            if platform.system() == "Windows":
                os.startfile(path)
            elif platform.system() == "Darwin":
                subprocess.run(["open", path])
            else:
                subprocess.run(["xdg-open", path])
        except OSError:
            print("Exception thrown :)")
            box = QtWidgets.QMessageBox(
                QtWidgets.QMessageBox.Information, "Game History",
                f"Previously generated games can be found at:\n{path}",
                QtWidgets.QMessageBox.Ok, self)
            box.setTextInteractionFlags(Qt.TextSelectableByMouse)
            box.show()

    def _on_menu_action_layout_editor(self):
        from randovania.gui.corruption_layout_editor import CorruptionLayoutEditor
        self.corruption_editor = CorruptionLayoutEditor()
        self.corruption_editor.show()
예제 #21
0
class MainWindow(QMainWindow, Ui_MainWindow, TabService, BackgroundTaskMixin):
    newer_version_signal = Signal(str, str)
    options_changed_signal = Signal()
    is_preview_mode: bool = False

    menu_new_version: Optional[QAction] = None
    _current_version_url: Optional[str] = None
    _options: Options
    _data_visualizer: Optional[DataEditorWindow] = None

    @property
    def _tab_widget(self):
        return self.tabWidget

    def __init__(self, options: Options, preview: bool):
        super().__init__()
        self.setupUi(self)
        self.setWindowTitle("Randovania {}".format(VERSION))
        self.is_preview_mode = preview
        self.setAcceptDrops(True)
        set_default_window_icon(self)

        self.intro_label.setText(
            self.intro_label.text().format(version=VERSION))

        if preview:
            debug.set_level(2)

        # Signals
        self.newer_version_signal.connect(self.display_new_version)
        self.background_tasks_button_lock_signal.connect(
            self.enable_buttons_with_background_tasks)
        self.progress_update_signal.connect(self.update_progress)
        self.stop_background_process_button.clicked.connect(
            self.stop_background_process)
        self.options_changed_signal.connect(self.on_options_changed)

        # Menu Bar
        self.menu_action_data_visualizer.triggered.connect(
            self._open_data_visualizer)
        self.menu_action_existing_seed_details.triggered.connect(
            self._open_existing_seed_details)
        self.menu_action_tracker.triggered.connect(self._open_tracker)
        self.menu_action_edit_new_database.triggered.connect(
            self._open_data_editor_default)
        self.menu_action_edit_existing_database.triggered.connect(
            self._open_data_editor_prompt)
        self.menu_action_export_iso.triggered.connect(self._export_iso)
        self.menu_action_validate_seed_after.triggered.connect(
            self._on_validate_seed_change)
        self.menu_action_timeout_generation_after_a_time_limit.triggered.connect(
            self._on_generate_time_limit_change)

        self.menu_action_export_iso.setEnabled(False)

        _translate = QtCore.QCoreApplication.translate
        self.tabs = []

        from randovania.gui.game_patches_window import GamePatchesWindow
        from randovania.gui.iso_management_window import ISOManagementWindow
        from randovania.gui.logic_settings_window import LogicSettingsWindow
        from randovania.gui.cosmetic_window import CosmeticWindow
        from randovania.gui.main_rules import MainRulesWindow

        self.tab_windows = [
            (ISOManagementWindow, "ROM Settings"),
            (GamePatchesWindow, "Game Patches"),
            (MainRulesWindow, "Main Rules"),
            (LogicSettingsWindow, "Logic Settings"),
            (CosmeticWindow, "Cosmetic"),
        ]

        for i, tab in enumerate(self.tab_windows):
            self.windows.append(tab[0](self, self, options))
            self.tabs.append(self.windows[i].centralWidget)
            self.tabWidget.insertTab(i + 1, self.tabs[i],
                                     _translate("MainWindow", tab[1]))

        # Setting this event only now, so all options changed trigger only once
        options.on_options_changed = self.options_changed_signal.emit
        self._options = options
        with options:
            self.on_options_changed()

        self.tabWidget.setCurrentIndex(0)

        # Update hints text
        self._update_hints_text()

    def closeEvent(self, event):
        self.stop_background_process()
        for window in self.windows:
            window.closeEvent(event)
        super().closeEvent(event)

    def dragEnterEvent(self, event):
        for url in event.mimeData().urls():
            if os.path.splitext(url.toLocalFile())[1] == ".iso":
                event.acceptProposedAction()
                return

    def dropEvent(self, event):
        from randovania.gui.iso_management_window import ISOManagementWindow

        for url in event.mimeData().urls():
            iso_path = url.toLocalFile()
            if os.path.splitext(iso_path)[1] == ".iso":
                self.get_tab(ISOManagementWindow).load_game(Path(iso_path))
                return

    # Releases info
    def request_new_data(self):
        asyncio.get_event_loop().create_task(
            github_releases_data.get_releases()).add_done_callback(
                self._on_releases_data)

    def _on_releases_data(self, task: asyncio.Task):
        releases = task.result()
        current_version = update_checker.strict_current_version()
        last_changelog = self._options.last_changelog_displayed

        all_change_logs, new_change_logs, version_to_display = update_checker.versions_to_display_for_releases(
            current_version, last_changelog, releases)

        if version_to_display is not None:
            self.display_new_version(version_to_display)

        if all_change_logs:
            changelog_tab = QtWidgets.QWidget()
            changelog_tab.setObjectName("changelog_tab")
            changelog_tab_layout = QtWidgets.QVBoxLayout(changelog_tab)
            changelog_tab_layout.setObjectName("changelog_tab_layout")
            changelog_scroll_area = QtWidgets.QScrollArea(changelog_tab)
            changelog_scroll_area.setWidgetResizable(True)
            changelog_scroll_area.setObjectName("changelog_scroll_area")
            changelog_scroll_contents = QtWidgets.QWidget()
            changelog_scroll_contents.setGeometry(QtCore.QRect(0, 0, 489, 337))
            changelog_scroll_contents.setObjectName(
                "changelog_scroll_contents")
            changelog_scroll_layout = QtWidgets.QVBoxLayout(
                changelog_scroll_contents)
            changelog_scroll_layout.setObjectName("changelog_scroll_layout")
            changelog_label = QtWidgets.QLabel(changelog_scroll_contents)
            changelog_label.setObjectName("changelog_label")
            changelog_label.setText(
                markdown.markdown("\n".join(all_change_logs)))
            changelog_scroll_layout.addWidget(changelog_label)
            changelog_scroll_area.setWidget(changelog_scroll_contents)
            changelog_tab_layout.addWidget(changelog_scroll_area)
            self.welcome_tab_widget.addTab(changelog_tab, "Change Log")

        if new_change_logs:
            QMessageBox.information(
                self, "What's new",
                markdown.markdown("\n".join(new_change_logs)))
            with self._options as options:
                options.last_changelog_displayed = current_version

    def display_new_version(self, version: update_checker.VersionDescription):
        if self.menu_new_version is None:
            self.menu_new_version = QAction("", self)
            self.menu_new_version.triggered.connect(self.open_version_link)
            self.menu_bar.addAction(self.menu_new_version)

        self.menu_new_version.setText("New version available: {}".format(
            version.tag_name))
        self._current_version_url = version.html_url

    def open_version_link(self):
        if self._current_version_url is None:
            raise RuntimeError(
                "Called open_version_link, but _current_version_url is None")

        QDesktopServices.openUrl(QUrl(self._current_version_url))

    # Options
    def on_options_changed(self):
        for window in self.windows:
            window.on_options_changed(self._options)

        self.menu_action_validate_seed_after.setChecked(
            self._options.advanced_validate_seed_after)
        self.menu_action_timeout_generation_after_a_time_limit.setChecked(
            self._options.advanced_timeout_during_generation)

    # Menu Actions
    def _open_data_visualizer(self):
        self.open_data_visualizer_at(None, None)

    def open_data_visualizer_at(
        self,
        world_name: Optional[str],
        area_name: Optional[str],
    ):
        self._data_visualizer = DataEditorWindow(
            default_data.decode_default_prime2(), False)

        if world_name is not None:
            self._data_visualizer.focus_on_world(world_name)

        if area_name is not None:
            self._data_visualizer.focus_on_area(area_name)

        self._data_visualizer.show()

    def _open_data_editor_default(self):
        self._data_editor = DataEditorWindow(
            default_data.decode_default_prime2(), True)
        self._data_editor.show()

    def _open_data_editor_prompt(self):
        database_path = prompt_user_for_database_file(self)
        if database_path is None:
            return

        with database_path.open("r") as database_file:
            self._data_editor = DataEditorWindow(json.load(database_file),
                                                 True)
            self._data_editor.show()

    def _open_existing_seed_details(self):
        json_path = prompt_user_for_seed_log(self)
        if json_path is None:
            return

        self._seed_details = SeedDetailsWindow(json_path)
        self._seed_details.show()

    def _open_tracker(self):
        try:
            self._tracker = TrackerWindow(self._options.tracker_files_path,
                                          self._options.layout_configuration)
        except InvalidLayoutForTracker as e:
            QMessageBox.critical(self, "Unsupported configuration for Tracker",
                                 str(e))
            return

        self._tracker.show()

    def _export_iso(self):
        pass

    def _on_validate_seed_change(self):
        old_value = self._options.advanced_validate_seed_after
        new_value = self.menu_action_validate_seed_after.isChecked()

        if old_value and not new_value:
            box = QMessageBox(self)
            box.setWindowTitle("Disable validation?")
            box.setText(_DISABLE_VALIDATION_WARNING)
            box.setIcon(QMessageBox.Warning)
            box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
            box.setDefaultButton(QMessageBox.No)
            user_response = box.exec_()
            if user_response != QMessageBox.Yes:
                self.menu_action_validate_seed_after.setChecked(True)
                return

        with self._options as options:
            options.advanced_validate_seed_after = new_value

    def _on_generate_time_limit_change(self):
        is_checked = self.menu_action_timeout_generation_after_a_time_limit.isChecked(
        )
        with self._options as options:
            options.advanced_timeout_during_generation = is_checked

    def _update_hints_text(self):
        game_description = default_database.default_prime2_game_description()

        number_for_hint_type = {
            hint_type: i + 1
            for i, hint_type in enumerate(LoreType)
        }
        used_hint_types = set()

        self.hint_tree_widget.setSortingEnabled(False)

        # TODO: This ignores the Dark World names. But there's currently no logbook nodes in Dark World.
        for world in game_description.world_list.worlds:

            world_item = QtWidgets.QTreeWidgetItem(self.hint_tree_widget)
            world_item.setText(0, world.name)
            world_item.setExpanded(True)

            for area in world.areas:
                hint_types = {}

                for node in area.nodes:
                    if isinstance(node, LogbookNode):
                        if node.required_translator is not None:
                            hint_types[
                                node.
                                lore_type] = node.required_translator.short_name
                        else:
                            hint_types[node.lore_type] = "✓"

                if hint_types:
                    area_item = QtWidgets.QTreeWidgetItem(world_item)
                    area_item.setText(0, area.name)

                    for hint_type, text in hint_types.items():
                        area_item.setText(number_for_hint_type[hint_type],
                                          text)
                        used_hint_types.add(hint_type)

        self.hint_tree_widget.resizeColumnToContents(0)
        self.hint_tree_widget.setSortingEnabled(True)
        self.hint_tree_widget.sortByColumn(0, QtCore.Qt.AscendingOrder)

        for hint_type in used_hint_types:
            self.hint_tree_widget.headerItem().setText(
                number_for_hint_type[hint_type], hint_type.long_name)

    # Background Process

    def enable_buttons_with_background_tasks(self, value: bool):
        self.stop_background_process_button.setEnabled(not value)

    def update_progress(self, message: str, percentage: int):
        self.progress_label.setText(message)
        if "Aborted" in message:
            percentage = 0
        if percentage >= 0:
            self.progress_bar.setRange(0, 100)
            self.progress_bar.setValue(percentage)
        else:
            self.progress_bar.setRange(0, 0)
예제 #22
0
 def _open_data_editor_default(self):
     self._data_editor = DataEditorWindow(
         default_data.decode_default_prime2(), True)
     self._data_editor.show()
예제 #23
0
 def _open_data_visualizer(self):
     self._data_visualizer = DataEditorWindow(
         default_data.decode_default_prime2(), False)
     self._data_visualizer.show()
예제 #24
0
 def _open_data_editor_for_game(self, game: RandovaniaGame):
     self._data_editor = DataEditorWindow.open_internal_data(game, True)
     self._data_editor.show()
예제 #25
0
class MainWindow(WindowManager, Ui_MainWindow):
    newer_version_signal = Signal(str, str)
    options_changed_signal = Signal()
    _is_preview_mode: bool = False

    menu_new_version: Optional[QAction] = None
    _current_version_url: Optional[str] = None
    _options: Options
    _data_visualizer: Optional[DataEditorWindow] = None
    _map_tracker: TrackerWindow
    _preset_manager: PresetManager
    game_session_window: Optional[GameSessionWindow] = None
    _login_window: Optional[QDialog] = None
    GameDetailsSignal = Signal(LayoutDescription)

    @property
    def _tab_widget(self):
        return self.main_tab_widget

    @property
    def preset_manager(self) -> PresetManager:
        return self._preset_manager

    @property
    def main_window(self) -> QMainWindow:
        return self

    @property
    def is_preview_mode(self) -> bool:
        return self._is_preview_mode

    def __init__(self, options: Options, preset_manager: PresetManager,
                 network_client: QtNetworkClient, preview: bool):
        super().__init__()
        self.setupUi(self)
        self.setWindowTitle("Randovania {}".format(VERSION))
        self._is_preview_mode = preview
        self.setAcceptDrops(True)
        common_qt_lib.set_default_window_icon(self)

        # Remove all hardcoded link color
        about_document: QtGui.QTextDocument = self.about_text_browser.document(
        )
        about_document.setHtml(about_document.toHtml().replace(
            "color:#0000ff;", ""))
        self.browse_racetime_label.setText(
            self.browse_racetime_label.text().replace("color:#0000ff;", ""))

        self.intro_label.setText(
            self.intro_label.text().format(version=VERSION))

        self._preset_manager = preset_manager
        self.network_client = network_client

        if preview:
            debug.set_level(2)

        # Signals
        self.newer_version_signal.connect(self.display_new_version)
        self.options_changed_signal.connect(self.on_options_changed)
        self.GameDetailsSignal.connect(self._open_game_details)

        self.intro_play_now_button.clicked.connect(
            lambda: self.welcome_tab_widget.setCurrentWidget(self.tab_play))
        self.open_faq_button.clicked.connect(self._open_faq)
        self.open_database_viewer_button.clicked.connect(
            partial(self._open_data_visualizer_for_game,
                    RandovaniaGame.PRIME2))

        self.import_permalink_button.clicked.connect(self._import_permalink)
        self.import_game_file_button.clicked.connect(self._import_spoiler_log)
        self.browse_racetime_button.clicked.connect(self._browse_racetime)
        self.browse_sessions_button.clicked.connect(
            self._browse_for_game_session)
        self.host_new_game_button.clicked.connect(self._host_game_session)
        self.create_new_seed_button.clicked.connect(
            lambda: self.welcome_tab_widget.setCurrentWidget(self.
                                                             tab_create_seed))

        # Menu Bar
        for action, game in ((self.menu_action_visualize_prime_1,
                              RandovaniaGame.PRIME1),
                             (self.menu_action_visualize_prime_2,
                              RandovaniaGame.PRIME2),
                             (self.menu_action_visualize_prime_3,
                              RandovaniaGame.PRIME3)):
            action.triggered.connect(
                partial(self._open_data_visualizer_for_game, game))

        for action, game in ((self.menu_action_edit_prime_1,
                              RandovaniaGame.PRIME1),
                             (self.menu_action_edit_prime_2,
                              RandovaniaGame.PRIME2),
                             (self.menu_action_edit_prime_3,
                              RandovaniaGame.PRIME3)):
            action.triggered.connect(
                partial(self._open_data_editor_for_game, game))

        self.menu_action_item_tracker.triggered.connect(
            self._open_item_tracker)
        self.menu_action_map_tracker.triggered.connect(
            self._on_menu_action_map_tracker)
        self.menu_action_edit_existing_database.triggered.connect(
            self._open_data_editor_prompt)
        self.menu_action_validate_seed_after.triggered.connect(
            self._on_validate_seed_change)
        self.menu_action_timeout_generation_after_a_time_limit.triggered.connect(
            self._on_generate_time_limit_change)
        self.menu_action_dark_mode.triggered.connect(
            self._on_menu_action_dark_mode)
        self.menu_action_open_auto_tracker.triggered.connect(
            self._open_auto_tracker)
        self.menu_action_previously_generated_games.triggered.connect(
            self._on_menu_action_previously_generated_games)
        self.menu_action_layout_editor.triggered.connect(
            self._on_menu_action_layout_editor)
        self.action_login_window.triggered.connect(self._action_login_window)

        self.generate_seed_tab = GenerateSeedTab(self, self, options)
        self.generate_seed_tab.setup_ui()

        # Needs the GenerateSeedTab
        self._setup_difficulties_menu()

        # Setting this event only now, so all options changed trigger only once
        options.on_options_changed = self.options_changed_signal.emit
        self._options = options
        with options:
            self.on_options_changed()

        self.main_tab_widget.setCurrentIndex(0)

        # Update hints text
        self._update_hints_text()

    def closeEvent(self, event):
        self.generate_seed_tab.stop_background_process()
        super().closeEvent(event)

    # Generate Seed
    def _open_faq(self):
        self.main_tab_widget.setCurrentWidget(self.help_tab)
        self.help_tab_widget.setCurrentWidget(self.tab_faq)

    async def generate_seed_from_permalink(self, permalink):
        from randovania.interface_common.status_update_lib import ProgressUpdateCallable
        from randovania.gui.dialog.background_process_dialog import BackgroundProcessDialog

        def work(progress_update: ProgressUpdateCallable):
            from randovania.interface_common import simplified_patcher
            layout = simplified_patcher.generate_layout(
                progress_update=progress_update,
                permalink=permalink,
                options=self._options)
            progress_update(f"Success! (Seed hash: {layout.shareable_hash})",
                            1)
            return layout

        new_layout = await BackgroundProcessDialog.open_for_background_task(
            work, "Creating a game...")
        self.open_game_details(new_layout)

    @asyncSlot()
    async def _import_permalink(self):
        dialog = PermalinkDialog()
        result = await async_dialog.execute_dialog(dialog)
        if result == QDialog.Accepted:
            permalink = dialog.get_permalink_from_field()
            await self.generate_seed_from_permalink(permalink)

    def _import_spoiler_log(self):
        json_path = common_qt_lib.prompt_user_for_input_game_log(self)
        if json_path is not None:
            layout = LayoutDescription.from_file(json_path)
            self.open_game_details(layout)

    @asyncSlot()
    async def _browse_racetime(self):
        from randovania.gui.dialog.racetime_browser_dialog import RacetimeBrowserDialog
        dialog = RacetimeBrowserDialog()
        if not await dialog.refresh():
            return
        result = await async_dialog.execute_dialog(dialog)
        if result == QDialog.Accepted:
            await self.generate_seed_from_permalink(dialog.permalink)

    async def _game_session_active(self) -> bool:
        if self.game_session_window is None or self.game_session_window.has_closed:
            return False
        else:
            await async_dialog.message_box(
                self, QtWidgets.QMessageBox.Critical,
                "Game Session in progress",
                "There's already a game session window open. Please close it first.",
                QMessageBox.Ok)
            self.game_session_window.activateWindow()
            return True

    async def _ensure_logged_in(self) -> bool:
        network_client = self.network_client
        if network_client.connection_state == ConnectionState.Connected:
            return True

        if network_client.connection_state.is_disconnected:
            message_box = QtWidgets.QMessageBox(QtWidgets.QMessageBox.NoIcon,
                                                "Connecting",
                                                "Connecting to server...",
                                                QtWidgets.QMessageBox.Cancel,
                                                self)

            connecting = network_client.connect_to_server()
            message_box.rejected.connect(connecting.cancel)
            message_box.show()
            try:
                await connecting
            finally:
                message_box.close()

        if network_client.current_user is None:
            await async_dialog.execute_dialog(LoginPromptDialog(network_client)
                                              )

        return network_client.current_user is not None

    @asyncSlot()
    @handle_network_errors
    async def _browse_for_game_session(self):
        if await self._game_session_active():
            return

        if not await self._ensure_logged_in():
            return

        network_client = self.network_client
        browser = GameSessionBrowserDialog(network_client)
        await browser.refresh()
        if await async_dialog.execute_dialog(browser) == browser.Accepted:
            self.game_session_window = await GameSessionWindow.create_and_update(
                network_client, common_qt_lib.get_game_connection(),
                self.preset_manager, self, self._options)
            if self.game_session_window is not None:
                self.game_session_window.show()

    @asyncSlot()
    @handle_network_errors
    async def _action_login_window(self):
        if self._login_window is not None:
            return self._login_window.show()

        self._login_window = LoginPromptDialog(self.network_client)
        try:
            await async_dialog.execute_dialog(self._login_window)
        finally:
            self._login_window = None

    @asyncSlot()
    @handle_network_errors
    async def _host_game_session(self):
        if await self._game_session_active():
            return

        if not await self._ensure_logged_in():
            return

        dialog = QInputDialog(self)
        dialog.setModal(True)
        dialog.setWindowTitle("Enter session name")
        dialog.setLabelText("Select a name for the session:")
        if await async_dialog.execute_dialog(dialog) != dialog.Accepted:
            return

        await self.network_client.create_new_session(dialog.textValue())
        self.game_session_window = await GameSessionWindow.create_and_update(
            self.network_client, common_qt_lib.get_game_connection(),
            self.preset_manager, self, self._options)
        if self.game_session_window is not None:
            self.game_session_window.show()

    def open_game_details(self, layout: LayoutDescription):
        self.GameDetailsSignal.emit(layout)

    def _open_game_details(self, layout: LayoutDescription):
        from randovania.gui.seed_details_window import SeedDetailsWindow
        details_window = SeedDetailsWindow(self, self._options)
        details_window.update_layout_description(layout)
        details_window.show()
        self.track_window(details_window)

    # Releases info
    async def request_new_data(self):
        await self._on_releases_data(await github_releases_data.get_releases())

    async def _on_releases_data(self, releases: Optional[List[dict]]):
        current_version = update_checker.strict_current_version()
        last_changelog = self._options.last_changelog_displayed

        all_change_logs, new_change_logs, version_to_display = update_checker.versions_to_display_for_releases(
            current_version, last_changelog, releases)

        if version_to_display is not None:
            self.display_new_version(version_to_display)

        if all_change_logs:
            changelog_tab = QtWidgets.QWidget()
            changelog_tab.setObjectName("changelog_tab")
            changelog_tab_layout = QtWidgets.QVBoxLayout(changelog_tab)
            changelog_tab_layout.setContentsMargins(0, 0, 0, 0)
            changelog_tab_layout.setObjectName("changelog_tab_layout")
            changelog_scroll_area = QtWidgets.QScrollArea(changelog_tab)
            changelog_scroll_area.setWidgetResizable(True)
            changelog_scroll_area.setObjectName("changelog_scroll_area")
            changelog_scroll_contents = QtWidgets.QWidget()
            changelog_scroll_contents.setGeometry(QtCore.QRect(0, 0, 489, 337))
            changelog_scroll_contents.setObjectName(
                "changelog_scroll_contents")
            changelog_scroll_layout = QtWidgets.QVBoxLayout(
                changelog_scroll_contents)
            changelog_scroll_layout.setObjectName("changelog_scroll_layout")
            changelog_label = QtWidgets.QLabel(changelog_scroll_contents)
            changelog_label.setObjectName("changelog_label")
            changelog_label.setText(
                markdown.markdown("\n".join(all_change_logs)))
            changelog_label.setWordWrap(True)
            changelog_scroll_layout.addWidget(changelog_label)
            changelog_scroll_area.setWidget(changelog_scroll_contents)
            changelog_tab_layout.addWidget(changelog_scroll_area)
            self.help_tab_widget.addTab(changelog_tab, "Change Log")

        if new_change_logs:
            await async_dialog.message_box(
                self, QtWidgets.QMessageBox.Information, "What's new",
                markdown.markdown("\n".join(new_change_logs)))
            with self._options as options:
                options.last_changelog_displayed = current_version

    def display_new_version(self, version: update_checker.VersionDescription):
        if self.menu_new_version is None:
            self.menu_new_version = QAction("", self)
            self.menu_new_version.triggered.connect(self.open_version_link)
            self.menu_bar.addAction(self.menu_new_version)

        self.menu_new_version.setText("New version available: {}".format(
            version.tag_name))
        self._current_version_url = version.html_url

    def open_version_link(self):
        if self._current_version_url is None:
            raise RuntimeError(
                "Called open_version_link, but _current_version_url is None")

        QDesktopServices.openUrl(QUrl(self._current_version_url))

    # Options
    def on_options_changed(self):
        self.menu_action_validate_seed_after.setChecked(
            self._options.advanced_validate_seed_after)
        self.menu_action_timeout_generation_after_a_time_limit.setChecked(
            self._options.advanced_timeout_during_generation)
        self.menu_action_dark_mode.setChecked(self._options.dark_mode)

        self.generate_seed_tab.on_options_changed(self._options)
        theme.set_dark_theme(self._options.dark_mode)

    # Menu Actions
    def _open_data_visualizer_for_game(self, game: RandovaniaGame):
        self.open_data_visualizer_at(None, None, game)

    def open_data_visualizer_at(
        self,
        world_name: Optional[str],
        area_name: Optional[str],
        game: RandovaniaGame = RandovaniaGame.PRIME2,
    ):
        self._data_visualizer = DataEditorWindow.open_internal_data(
            game, False)

        if world_name is not None:
            self._data_visualizer.focus_on_world(world_name)

        if area_name is not None:
            self._data_visualizer.focus_on_area(area_name)

        self._data_visualizer.show()

    def _open_data_editor_for_game(self, game: RandovaniaGame):
        self._data_editor = DataEditorWindow.open_internal_data(game, True)
        self._data_editor.show()

    def _open_data_editor_prompt(self):
        database_path = common_qt_lib.prompt_user_for_database_file(self)
        if database_path is None:
            return

        with database_path.open("r") as database_file:
            self._data_editor = DataEditorWindow(json.load(database_file),
                                                 database_path, False, True)
            self._data_editor.show()

    @asyncSlot()
    async def _on_menu_action_map_tracker(self):
        dialog = QtWidgets.QInputDialog(self)
        dialog.setWindowTitle("Map Tracker")
        dialog.setLabelText("Select preset used for the tracker.")
        dialog.setComboBoxItems(
            [preset.name for preset in self._preset_manager.all_presets])
        dialog.setTextValue(self._options.selected_preset_name)
        result = await async_dialog.execute_dialog(dialog)
        if result == QtWidgets.QDialog.Accepted:
            preset = self._preset_manager.preset_for_name(dialog.textValue())
            self.open_map_tracker(preset.get_preset().layout_configuration)

    def open_map_tracker(self, configuration: LayoutConfiguration):
        try:
            self._map_tracker = TrackerWindow(self._options.tracker_files_path,
                                              configuration)
        except InvalidLayoutForTracker as e:
            QMessageBox.critical(self, "Unsupported configuration for Tracker",
                                 str(e))
            return

        self._map_tracker.show()

    def _open_item_tracker(self):
        # Importing this at root level seems to crash linux tests :(
        from PySide2.QtWebEngineWidgets import QWebEngineView

        tracker_window = QMainWindow()
        tracker_window.setWindowTitle("Item Tracker")
        tracker_window.resize(370, 380)

        web_view = QWebEngineView(tracker_window)
        tracker_window.setCentralWidget(web_view)

        self.web_view = web_view

        def update_window_icon():
            tracker_window.setWindowIcon(web_view.icon())

        web_view.iconChanged.connect(update_window_icon)
        web_view.load(
            QUrl("https://spaghettitoastbook.github.io/echoes/tracker/"))

        tracker_window.show()
        self._item_tracker_window = tracker_window

    # Difficulties stuff

    def _exec_trick_details(self, popup: TrickDetailsPopup):
        self._trick_details_popup = popup
        self._trick_details_popup.setWindowModality(Qt.WindowModal)
        self._trick_details_popup.open()

    def _open_trick_details_popup(self, trick: TrickResourceInfo,
                                  level: LayoutTrickLevel):
        self._exec_trick_details(
            TrickDetailsPopup(
                self,
                self,
                default_database.default_prime2_game_description(),
                trick,
                level,
            ))

    def _setup_difficulties_menu(self):
        game = default_database.default_prime2_game_description()
        tricks_in_use = used_tricks(game)

        for trick in sorted(game.resource_database.trick,
                            key=lambda _trick: _trick.long_name):
            if trick not in tricks_in_use:
                continue

            trick_menu = QMenu(self)
            trick_menu.setTitle(trick.long_name)
            self.menu_trick_details.addAction(trick_menu.menuAction())

            used_difficulties = difficulties_for_trick(game, trick)
            for i, trick_level in enumerate(iterate_enum(LayoutTrickLevel)):
                if trick_level in used_difficulties:
                    difficulty_action = QAction(self)
                    difficulty_action.setText(trick_level.long_name)
                    trick_menu.addAction(difficulty_action)
                    difficulty_action.triggered.connect(
                        functools.partial(self._open_trick_details_popup,
                                          trick, trick_level))

    # ==========

    @asyncSlot()
    async def _on_validate_seed_change(self):
        old_value = self._options.advanced_validate_seed_after
        new_value = self.menu_action_validate_seed_after.isChecked()

        if old_value and not new_value:
            box = QMessageBox(self)
            box.setWindowTitle("Disable validation?")
            box.setText(_DISABLE_VALIDATION_WARNING)
            box.setIcon(QMessageBox.Warning)
            box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
            box.setDefaultButton(QMessageBox.No)
            user_response = await async_dialog.execute_dialog(box)
            if user_response != QMessageBox.Yes:
                self.menu_action_validate_seed_after.setChecked(True)
                return

        with self._options as options:
            options.advanced_validate_seed_after = new_value

    def _on_generate_time_limit_change(self):
        is_checked = self.menu_action_timeout_generation_after_a_time_limit.isChecked(
        )
        with self._options as options:
            options.advanced_timeout_during_generation = is_checked

    def _on_menu_action_dark_mode(self):
        with self._options as options:
            options.dark_mode = self.menu_action_dark_mode.isChecked()

    def _open_auto_tracker(self):
        from randovania.gui.auto_tracker_window import AutoTrackerWindow
        self.auto_tracker_window = AutoTrackerWindow(
            common_qt_lib.get_game_connection(), self._options)
        self.auto_tracker_window.show()

    def _on_menu_action_previously_generated_games(self):
        path = self._options.data_dir.joinpath("game_history")
        if platform.system() == "Windows":
            os.startfile(path)
        else:
            box = QtWidgets.QMessageBox(
                QtWidgets.QMessageBox.Information, "Game History",
                f"Previously generated games can be found at:\n{path}",
                QtWidgets.QMessageBox.Ok, self)
            box.setTextInteractionFlags(Qt.TextSelectableByMouse)
            box.show()

    def _on_menu_action_layout_editor(self):
        from randovania.gui.corruption_layout_editor import CorruptionLayoutEditor
        self.corruption_editor = CorruptionLayoutEditor()
        self.corruption_editor.show()

    def _update_hints_text(self):
        game_description = default_database.default_prime2_game_description()
        item_database = default_database.default_prime2_item_database()

        rows = []

        for item in item_database.major_items.values():
            rows.append((
                item.name,
                item.item_category.hint_details[1],
                item.item_category.general_details[1],
                item.broad_category.hint_details[1],
            ))

        from randovania.games.prime.echoes_items import DARK_TEMPLE_KEY_NAMES
        for dark_temple_key in DARK_TEMPLE_KEY_NAMES:
            rows.append((
                dark_temple_key.format("").strip(),
                ItemCategory.TEMPLE_KEY.hint_details[1],
                ItemCategory.TEMPLE_KEY.general_details[1],
                ItemCategory.KEY.hint_details[1],
            ))

        rows.append((
            "Sky Temple Key",
            ItemCategory.SKY_TEMPLE_KEY.hint_details[1],
            ItemCategory.SKY_TEMPLE_KEY.general_details[1],
            ItemCategory.KEY.hint_details[1],
        ))

        for item in item_database.ammo.values():
            rows.append((
                item.name,
                ItemCategory.EXPANSION.hint_details[1],
                ItemCategory.EXPANSION.general_details[1],
                item.broad_category.hint_details[1],
            ))

        self.hint_item_names_tree_widget.setRowCount(len(rows))
        for i, elements in enumerate(rows):
            for j, element in enumerate(elements):
                self.hint_item_names_tree_widget.setItem(
                    i, j, QtWidgets.QTableWidgetItem(element))

        for i in range(4):
            self.hint_item_names_tree_widget.resizeColumnToContents(i)

        number_for_hint_type = {
            hint_type: i + 1
            for i, hint_type in enumerate(LoreType)
        }
        used_hint_types = set()

        self.hint_tree_widget.setSortingEnabled(False)

        # TODO: This ignores the Dark World names. But there's currently no logbook nodes in Dark World.
        for world in game_description.world_list.worlds:

            world_item = QtWidgets.QTreeWidgetItem(self.hint_tree_widget)
            world_item.setText(0, world.name)
            world_item.setExpanded(True)

            for area in world.areas:
                hint_types = {}

                for node in area.nodes:
                    if isinstance(node, LogbookNode):
                        if node.required_translator is not None:
                            hint_types[
                                node.
                                lore_type] = node.required_translator.short_name
                        else:
                            hint_types[node.lore_type] = "✓"

                if hint_types:
                    area_item = QtWidgets.QTreeWidgetItem(world_item)
                    area_item.setText(0, area.name)

                    for hint_type, text in hint_types.items():
                        area_item.setText(number_for_hint_type[hint_type],
                                          text)
                        used_hint_types.add(hint_type)

        self.hint_tree_widget.resizeColumnToContents(0)
        self.hint_tree_widget.setSortingEnabled(True)
        self.hint_tree_widget.sortByColumn(0, QtCore.Qt.AscendingOrder)

        for hint_type in used_hint_types:
            self.hint_tree_widget.headerItem().setText(
                number_for_hint_type[hint_type], hint_type.long_name)
예제 #26
0
class MainWindow(QMainWindow, Ui_MainWindow, WindowManager,
                 BackgroundTaskMixin):
    newer_version_signal = Signal(str, str)
    options_changed_signal = Signal()
    _is_preview_mode: bool = False

    menu_new_version: Optional[QAction] = None
    _current_version_url: Optional[str] = None
    _options: Options
    _data_visualizer: Optional[DataEditorWindow] = None
    _details_window: SeedDetailsWindow
    _map_tracker: TrackerWindow
    _preset_manager: PresetManager

    @property
    def _tab_widget(self):
        return self.main_tab_widget

    @property
    def preset_manager(self) -> PresetManager:
        return self._preset_manager

    @property
    def main_window(self) -> QMainWindow:
        return self

    @property
    def is_preview_mode(self) -> bool:
        return self._is_preview_mode

    def __init__(self, options: Options, preset_manager: PresetManager,
                 preview: bool):
        super().__init__()
        self.setupUi(self)
        self.setWindowTitle("Randovania {}".format(VERSION))
        self._is_preview_mode = preview
        self.setAcceptDrops(True)
        common_qt_lib.set_default_window_icon(self)

        self.intro_label.setText(
            self.intro_label.text().format(version=VERSION))

        self._preset_manager = preset_manager

        if preview:
            debug.set_level(2)

        # Signals
        self.newer_version_signal.connect(self.display_new_version)
        self.background_tasks_button_lock_signal.connect(
            self.enable_buttons_with_background_tasks)
        self.progress_update_signal.connect(self.update_progress)
        self.stop_background_process_button.clicked.connect(
            self.stop_background_process)
        self.options_changed_signal.connect(self.on_options_changed)

        self.intro_play_now_button.clicked.connect(
            lambda: self.welcome_tab_widget.setCurrentWidget(self.tab_play))
        self.open_faq_button.clicked.connect(self._open_faq)
        self.open_database_viewer_button.clicked.connect(
            self._open_data_visualizer)

        self.import_permalink_button.clicked.connect(self._import_permalink)
        self.import_game_file_button.clicked.connect(self._import_spoiler_log)
        self.create_new_seed_button.clicked.connect(
            lambda: self.welcome_tab_widget.setCurrentWidget(self.
                                                             tab_create_seed))

        # Menu Bar
        self.menu_action_data_visualizer.triggered.connect(
            self._open_data_visualizer)
        self.menu_action_item_tracker.triggered.connect(
            self._open_item_tracker)
        self.menu_action_edit_new_database.triggered.connect(
            self._open_data_editor_default)
        self.menu_action_edit_existing_database.triggered.connect(
            self._open_data_editor_prompt)
        self.menu_action_validate_seed_after.triggered.connect(
            self._on_validate_seed_change)
        self.menu_action_timeout_generation_after_a_time_limit.triggered.connect(
            self._on_generate_time_limit_change)

        self.generate_seed_tab = GenerateSeedTab(self, self, self, options)
        self.generate_seed_tab.setup_ui()
        self._details_window = SeedDetailsWindow(self, self, options)
        self._details_window.added_to_tab = False

        # Needs the GenerateSeedTab
        self._create_open_map_tracker_actions()
        self._setup_difficulties_menu()

        # Setting this event only now, so all options changed trigger only once
        options.on_options_changed = self.options_changed_signal.emit
        self._options = options
        with options:
            self.on_options_changed()

        self.main_tab_widget.setCurrentIndex(0)

        # Update hints text
        self._update_hints_text()

    def closeEvent(self, event):
        self.stop_background_process()
        super().closeEvent(event)

    # Generate Seed
    def _open_faq(self):
        self.main_tab_widget.setCurrentWidget(self.help_tab)
        self.help_tab_widget.setCurrentWidget(self.tab_faq)

    def _import_permalink(self):
        dialog = PermalinkDialog()
        result = dialog.exec_()
        if result == QDialog.Accepted:
            permalink = dialog.get_permalink_from_field()
            self.generate_seed_tab.generate_seed_from_permalink(permalink)

    def _import_spoiler_log(self):
        json_path = common_qt_lib.prompt_user_for_input_game_log(self)
        if json_path is not None:
            layout = LayoutDescription.from_file(json_path)
            self.show_seed_tab(layout)

    def show_seed_tab(self, layout: LayoutDescription):
        self._details_window.update_layout_description(layout)
        if not self._details_window.added_to_tab:
            self.welcome_tab_widget.addTab(self._details_window.centralWidget,
                                           "Game Details")
            self._details_window.added_to_tab = True
        self.welcome_tab_widget.setCurrentWidget(
            self._details_window.centralWidget)

    # Releases info
    def request_new_data(self):
        asyncio.get_event_loop().create_task(
            github_releases_data.get_releases()).add_done_callback(
                self._on_releases_data)

    def _on_releases_data(self, task: asyncio.Task):
        releases = task.result()
        current_version = update_checker.strict_current_version()
        last_changelog = self._options.last_changelog_displayed

        all_change_logs, new_change_logs, version_to_display = update_checker.versions_to_display_for_releases(
            current_version, last_changelog, releases)

        if version_to_display is not None:
            self.display_new_version(version_to_display)

        if all_change_logs:
            changelog_tab = QtWidgets.QWidget()
            changelog_tab.setObjectName("changelog_tab")
            changelog_tab_layout = QtWidgets.QVBoxLayout(changelog_tab)
            changelog_tab_layout.setContentsMargins(0, 0, 0, 0)
            changelog_tab_layout.setObjectName("changelog_tab_layout")
            changelog_scroll_area = QtWidgets.QScrollArea(changelog_tab)
            changelog_scroll_area.setWidgetResizable(True)
            changelog_scroll_area.setObjectName("changelog_scroll_area")
            changelog_scroll_contents = QtWidgets.QWidget()
            changelog_scroll_contents.setGeometry(QtCore.QRect(0, 0, 489, 337))
            changelog_scroll_contents.setObjectName(
                "changelog_scroll_contents")
            changelog_scroll_layout = QtWidgets.QVBoxLayout(
                changelog_scroll_contents)
            changelog_scroll_layout.setObjectName("changelog_scroll_layout")
            changelog_label = QtWidgets.QLabel(changelog_scroll_contents)
            changelog_label.setObjectName("changelog_label")
            changelog_label.setText(
                markdown.markdown("\n".join(all_change_logs)))
            changelog_label.setWordWrap(True)
            changelog_scroll_layout.addWidget(changelog_label)
            changelog_scroll_area.setWidget(changelog_scroll_contents)
            changelog_tab_layout.addWidget(changelog_scroll_area)
            self.help_tab_widget.addTab(changelog_tab, "Change Log")

        if new_change_logs:
            QMessageBox.information(
                self, "What's new",
                markdown.markdown("\n".join(new_change_logs)))
            with self._options as options:
                options.last_changelog_displayed = current_version

    def display_new_version(self, version: update_checker.VersionDescription):
        if self.menu_new_version is None:
            self.menu_new_version = QAction("", self)
            self.menu_new_version.triggered.connect(self.open_version_link)
            self.menu_bar.addAction(self.menu_new_version)

        self.menu_new_version.setText("New version available: {}".format(
            version.tag_name))
        self._current_version_url = version.html_url

    def open_version_link(self):
        if self._current_version_url is None:
            raise RuntimeError(
                "Called open_version_link, but _current_version_url is None")

        QDesktopServices.openUrl(QUrl(self._current_version_url))

    # Options
    def on_options_changed(self):
        self.menu_action_validate_seed_after.setChecked(
            self._options.advanced_validate_seed_after)
        self.menu_action_timeout_generation_after_a_time_limit.setChecked(
            self._options.advanced_timeout_during_generation)

        self.generate_seed_tab.on_options_changed(self._options)

    # Menu Actions
    def _open_data_visualizer(self):
        self.open_data_visualizer_at(None, None)

    def open_data_visualizer_at(
        self,
        world_name: Optional[str],
        area_name: Optional[str],
    ):
        self._data_visualizer = DataEditorWindow(
            default_data.decode_default_prime2(), False)

        if world_name is not None:
            self._data_visualizer.focus_on_world(world_name)

        if area_name is not None:
            self._data_visualizer.focus_on_area(area_name)

        self._data_visualizer.show()

    def _open_data_editor_default(self):
        self._data_editor = DataEditorWindow(
            default_data.decode_default_prime2(), True)
        self._data_editor.show()

    def _open_data_editor_prompt(self):
        database_path = common_qt_lib.prompt_user_for_database_file(self)
        if database_path is None:
            return

        with database_path.open("r") as database_file:
            self._data_editor = DataEditorWindow(json.load(database_file),
                                                 True)
            self._data_editor.show()

    def _create_open_map_tracker_actions(self):
        base_layout = self.preset_manager.default_preset.layout_configuration

        for trick_level in LayoutTrickLevel:
            if trick_level != LayoutTrickLevel.MINIMAL_RESTRICTIONS:
                action = QtWidgets.QAction(self)
                action.setText(trick_level.long_name)
                self.menu_map_tracker.addAction(action)

                configuration = dataclasses.replace(
                    base_layout,
                    trick_level_configuration=TrickLevelConfiguration(
                        trick_level, {}))
                action.triggered.connect(
                    partial(self.open_map_tracker, configuration))

    def open_map_tracker(self, configuration: LayoutConfiguration):
        try:
            self._map_tracker = TrackerWindow(self._options.tracker_files_path,
                                              configuration)
        except InvalidLayoutForTracker as e:
            QMessageBox.critical(self, "Unsupported configuration for Tracker",
                                 str(e))
            return

        self._map_tracker.show()

    def _open_item_tracker(self):
        # Importing this at root level seems to crash linux tests :(
        from PySide2.QtWebEngineWidgets import QWebEngineView

        tracker_window = QMainWindow()
        tracker_window.setWindowTitle("Item Tracker")
        tracker_window.resize(370, 380)

        web_view = QWebEngineView(tracker_window)
        tracker_window.setCentralWidget(web_view)

        self.web_view = web_view

        def update_window_icon():
            tracker_window.setWindowIcon(web_view.icon())

        web_view.iconChanged.connect(update_window_icon)
        web_view.load(
            QUrl("https://spaghettitoastbook.github.io/echoes/tracker/"))

        tracker_window.show()
        self._item_tracker_window = tracker_window

    # Difficulties stuff

    def _exec_trick_details(self, popup: TrickDetailsPopup):
        self._trick_details_popup = popup
        self._trick_details_popup.setWindowModality(Qt.WindowModal)
        self._trick_details_popup.open()

    def _open_trick_details_popup(self, trick: SimpleResourceInfo,
                                  level: LayoutTrickLevel):
        self._exec_trick_details(
            TrickDetailsPopup(
                self,
                self,
                default_database.default_prime2_game_description(),
                trick,
                level,
            ))

    def _open_difficulty_details_popup(self, difficulty: LayoutTrickLevel):
        self._exec_trick_details(
            TrickDetailsPopup(
                self,
                self,
                default_database.default_prime2_game_description(),
                None,
                difficulty,
            ))

    def _setup_difficulties_menu(self):
        game = default_database.default_prime2_game_description()
        for i, trick_level in enumerate(LayoutTrickLevel):
            if trick_level not in {
                    LayoutTrickLevel.NO_TRICKS,
                    LayoutTrickLevel.MINIMAL_RESTRICTIONS
            }:
                difficulty_action = QAction(self)
                difficulty_action.setText(trick_level.long_name)
                self.menu_difficulties.addAction(difficulty_action)
                difficulty_action.triggered.connect(
                    functools.partial(self._open_difficulty_details_popup,
                                      trick_level))

        configurable_tricks = TrickLevelConfiguration.all_possible_tricks()
        tricks_in_use = used_tricks(game.world_list)

        for trick in sorted(game.resource_database.trick,
                            key=lambda _trick: _trick.long_name):
            if trick.index not in configurable_tricks or trick not in tricks_in_use:
                continue

            trick_menu = QMenu(self)
            trick_menu.setTitle(trick.long_name)
            self.menu_trick_details.addAction(trick_menu.menuAction())

            used_difficulties = difficulties_for_trick(game.world_list, trick)
            for i, trick_level in enumerate(LayoutTrickLevel):
                if trick_level in used_difficulties:
                    difficulty_action = QAction(self)
                    difficulty_action.setText(trick_level.long_name)
                    trick_menu.addAction(difficulty_action)
                    difficulty_action.triggered.connect(
                        functools.partial(self._open_trick_details_popup,
                                          trick, trick_level))

    # ==========

    def _on_validate_seed_change(self):
        old_value = self._options.advanced_validate_seed_after
        new_value = self.menu_action_validate_seed_after.isChecked()

        if old_value and not new_value:
            box = QMessageBox(self)
            box.setWindowTitle("Disable validation?")
            box.setText(_DISABLE_VALIDATION_WARNING)
            box.setIcon(QMessageBox.Warning)
            box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
            box.setDefaultButton(QMessageBox.No)
            user_response = box.exec_()
            if user_response != QMessageBox.Yes:
                self.menu_action_validate_seed_after.setChecked(True)
                return

        with self._options as options:
            options.advanced_validate_seed_after = new_value

    def _on_generate_time_limit_change(self):
        is_checked = self.menu_action_timeout_generation_after_a_time_limit.isChecked(
        )
        with self._options as options:
            options.advanced_timeout_during_generation = is_checked

    def _update_hints_text(self):
        game_description = default_database.default_prime2_game_description()

        number_for_hint_type = {
            hint_type: i + 1
            for i, hint_type in enumerate(LoreType)
        }
        used_hint_types = set()

        self.hint_tree_widget.setSortingEnabled(False)

        # TODO: This ignores the Dark World names. But there's currently no logbook nodes in Dark World.
        for world in game_description.world_list.worlds:

            world_item = QtWidgets.QTreeWidgetItem(self.hint_tree_widget)
            world_item.setText(0, world.name)
            world_item.setExpanded(True)

            for area in world.areas:
                hint_types = {}

                for node in area.nodes:
                    if isinstance(node, LogbookNode):
                        if node.required_translator is not None:
                            hint_types[
                                node.
                                lore_type] = node.required_translator.short_name
                        else:
                            hint_types[node.lore_type] = "✓"

                if hint_types:
                    area_item = QtWidgets.QTreeWidgetItem(world_item)
                    area_item.setText(0, area.name)

                    for hint_type, text in hint_types.items():
                        area_item.setText(number_for_hint_type[hint_type],
                                          text)
                        used_hint_types.add(hint_type)

        self.hint_tree_widget.resizeColumnToContents(0)
        self.hint_tree_widget.setSortingEnabled(True)
        self.hint_tree_widget.sortByColumn(0, QtCore.Qt.AscendingOrder)

        for hint_type in used_hint_types:
            self.hint_tree_widget.headerItem().setText(
                number_for_hint_type[hint_type], hint_type.long_name)

    # Background Process

    def enable_buttons_with_background_tasks(self, value: bool):
        self.stop_background_process_button.setEnabled(not value)

    def update_progress(self, message: str, percentage: int):
        self.progress_label.setText(message)
        if "Aborted" in message:
            percentage = 0
        if percentage >= 0:
            self.progress_bar.setRange(0, 100)
            self.progress_bar.setValue(percentage)
        else:
            self.progress_bar.setRange(0, 0)