diff --git a/src/Settings/TSHSettingsWindow.py b/src/Settings/TSHSettingsWindow.py index 2e12195ee..d21d04d86 100644 --- a/src/Settings/TSHSettingsWindow.py +++ b/src/Settings/TSHSettingsWindow.py @@ -329,6 +329,20 @@ def UiMounted(self): QApplication.translate( "settings.api_keys", "Please note that the API Key will be stored in plain text on your computer") )) + APIKeySettings.append(( + QApplication.translate( + "settings.api_keys", "Start.gg API Token"), + "startgg", + "password", + "", + None, + QApplication.translate( + "settings.api_keys", "You can get a token from start.gg/admin/profile/developer") + "\n" + + QApplication.translate( + "settings.api_keys", "Please note that the API Token will be stored in plain text on your computer") + "\n" + + QApplication.translate( + "settings.api_keys", "Required to report set results back to Start.gg") + )) self.add_setting_widget(QApplication.translate( "settings", "API Keys"), SettingsWidget("api_keys", APIKeySettings)) diff --git a/src/TSHAppSignals.py b/src/TSHAppSignals.py new file mode 100644 index 000000000..ae83a6401 --- /dev/null +++ b/src/TSHAppSignals.py @@ -0,0 +1,9 @@ +from qtpy.QtCore import QObject, Signal + + +class _AppSignals(QObject): + # message, is_error + status_message = Signal(str, bool) + + +app_signals = _AppSignals() diff --git a/src/TSHGameAssetManager.py b/src/TSHGameAssetManager.py index be812029f..1f2b5a880 100644 --- a/src/TSHGameAssetManager.py +++ b/src/TSHGameAssetManager.py @@ -1092,7 +1092,8 @@ def UpdateCharacterModel(self, mods_active = True): "en_name": c, "display_name": self.characters[c].get("display_name"), "codename": self.characters[c].get("codename"), - "modded": self.characters[c].get("modded", False) + "modded": self.characters[c].get("modded", False), + "startgg_character_id": self.GetStartGGIdFromCodename(c), } if self.characters[c].get("display_name") != c: @@ -1659,6 +1660,20 @@ def GetStageFromStartGGId(self, smashgg_id: int): s[1].get("smashgg_id")) == str(smashgg_id)), None) return stage + def GetStartGGIdFromCodename(self, codename: str): + char = self.characters.get(codename) + if not char: + return None + smashgg_name = char.get("smashgg_name") + if not smashgg_name: + return None + current_game_id = str(self.selectedGame.get("smashgg_game_id", "")) if self.selectedGame else "" + for startgg_id, sgg_char in self.startgg_id_to_character.items(): + if sgg_char.get("name") == smashgg_name: + if current_game_id and str(sgg_char.get("videogameId", "")) == current_game_id: + return int(startgg_id) + return None + if not os.path.exists("./user_data/games"): os.makedirs("./user_data/games") diff --git a/src/TSHIndividualGameTracker.py b/src/TSHIndividualGameTracker.py index ca0603646..0cdc366a6 100644 --- a/src/TSHIndividualGameTracker.py +++ b/src/TSHIndividualGameTracker.py @@ -58,8 +58,13 @@ def uncheck_buttons_if_true(value, list_buttons): button.setChecked(False) stageWidget = QWidget() + stageWidget.setObjectName(f"gameRow_{index}") + stageWidget.setStyleSheet( + f"QWidget#gameRow_{index} {{ border: 1px solid palette(mid); border-radius: 4px; }}" + ) + stageWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum) outerLayout = QVBoxLayout() - outerLayout.setContentsMargins(0, 0, 0, 0) + outerLayout.setContentsMargins(6, 4, 6, 4) outerLayout.setSpacing(2) topRow = QWidget() @@ -87,7 +92,6 @@ def uncheck_buttons_if_true(value, list_buttons): stageTeam2Check.setText(QApplication.translate("app", "T{0}").format(2)) stageTeam2Check.setCheckable(True) stageTieCheck = QPushButton() - stageTieCheck.setMaximumWidth(40) stageTieCheck.setObjectName(f"stageTieCheck_{index}") stageTieCheck.setText(QApplication.translate("app", "Tie")) stageTieCheck.setCheckable(True) @@ -268,6 +272,7 @@ def SetStageCount(self, stage_count=0, carry_stage_codename=None): old.deleteLater() self.stage_widget_list = [] self.stage_order_list_layout = QVBoxLayout() + self.stage_order_list_layout.setSpacing(6) self.stage_order_list_widget = QWidget() self.stage_order_list_widget.setLayout(self.stage_order_list_layout) StateManager.Set(f"score.{self.scoreboard_number}.stages", {}) @@ -521,8 +526,49 @@ def DecrementScore(self, team=None, original_score=None): StateManager.Set(f"score.{self.scoreboard_number}.stages.{current_stage+1}.tie", stageTieCheck.isChecked()) self._EnsureCorrectRowCount() + def _EnforceBestOfLimit(self): + """Disable and clear win buttons for games that can't be played (set already decided).""" + if self.best_of <= 0: + for i, widget in enumerate(self.stage_widget_list): + for name in [f"stageTeam1Check_{i}", f"stageTeam2Check_{i}", f"stageTieCheck_{i}"]: + btn = widget.findChild(QPushButton, name) + if btn: + btn.setEnabled(True) + return + + wins_needed = (self.best_of // 2) + 1 + t1_wins = 0 + t2_wins = 0 + deciding_game = None + + for i in range(len(self.stage_widget_list)): + if t1_wins >= wins_needed or t2_wins >= wins_needed: + break + w = self._get_game_winner(i) + if w == 1: + t1_wins += 1 + elif w == 2: + t2_wins += 1 + if t1_wins >= wins_needed or t2_wins >= wins_needed: + deciding_game = i + + for i, widget in enumerate(self.stage_widget_list): + locked = deciding_game is not None and i > deciding_game + t1_btn = widget.findChild(QPushButton, f"stageTeam1Check_{i}") + t2_btn = widget.findChild(QPushButton, f"stageTeam2Check_{i}") + tie_btn = widget.findChild(QPushButton, f"stageTieCheck_{i}") + for btn in filter(None, [t1_btn, t2_btn, tie_btn]): + btn.setEnabled(not locked) + if locked and btn.isChecked(): + btn.setChecked(False) + if locked: + StateManager.Set(f"score.{self.scoreboard_number}.stages.{i+1}.t1_win", False) + StateManager.Set(f"score.{self.scoreboard_number}.stages.{i+1}.t2_win", False) + StateManager.Set(f"score.{self.scoreboard_number}.stages.{i+1}.tie", False) + @assert_gui_thread def StageResultsChanged(self): + self._EnforceBestOfLimit() team_1_score, team_2_score = 0, 0 for i in range(len(self.stage_widget_list)): stageTeam1Check = self.stage_widget_list[i].findChild(QPushButton, f"stageTeam1Check_{i}") @@ -539,16 +585,33 @@ def StageResultsChanged(self): @assert_gui_thread def SwapStageResults(self): - for i in range(len(self.stage_widget_list)): - stageTeam1Check = self.stage_widget_list[i].findChild(QPushButton, f"stageTeam1Check_{i}") - stageTeam2Check = self.stage_widget_list[i].findChild(QPushButton, f"stageTeam2Check_{i}") - stageTieCheck = self.stage_widget_list[i].findChild(QPushButton, f"stageTieCheck_{i}") - team_1_old_state, team_2_old_state = stageTeam1Check.isChecked(), stageTeam2Check.isChecked() - stageTeam1Check.setChecked(team_2_old_state) - stageTeam2Check.setChecked(team_1_old_state) - StateManager.Set(f"score.{self.scoreboard_number}.stages.{i+1}.t1_win", stageTeam1Check.isChecked()), - StateManager.Set(f"score.{self.scoreboard_number}.stages.{i+1}.t2_win", stageTeam2Check.isChecked()), - StateManager.Set(f"score.{self.scoreboard_number}.stages.{i+1}.tie", stageTieCheck.isChecked()), + old_syncing = self._syncing_chars + self._syncing_chars = True + try: + for i in range(len(self.stage_widget_list)): + stageWidget = self.stage_widget_list[i] + stageTeam1Check = stageWidget.findChild(QPushButton, f"stageTeam1Check_{i}") + stageTeam2Check = stageWidget.findChild(QPushButton, f"stageTeam2Check_{i}") + stageTieCheck = stageWidget.findChild(QPushButton, f"stageTieCheck_{i}") + team_1_old_state, team_2_old_state = stageTeam1Check.isChecked(), stageTeam2Check.isChecked() + stageTeam1Check.setChecked(team_2_old_state) + stageTeam2Check.setChecked(team_1_old_state) + StateManager.Set(f"score.{self.scoreboard_number}.stages.{i+1}.t1_win", stageTeam1Check.isChecked()) + StateManager.Set(f"score.{self.scoreboard_number}.stages.{i+1}.t2_win", stageTeam2Check.isChecked()) + StateManager.Set(f"score.{self.scoreboard_number}.stages.{i+1}.tie", stageTieCheck.isChecked()) + + for p in range(self.players_per_team): + for c in range(self.chars_per_player): + combo1 = stageWidget.findChild(QComboBox, f"charCombo_{i}_{0}_{p}_{c}") + combo2 = stageWidget.findChild(QComboBox, f"charCombo_{i}_{1}_{p}_{c}") + if combo1 is None or combo2 is None: + continue + idx1, idx2 = combo1.currentIndex(), combo2.currentIndex() + combo1.setCurrentIndex(idx2) + combo2.setCurrentIndex(idx1) + finally: + self._syncing_chars = old_syncing + self._EnforceBestOfLimit() @assert_gui_thread @@ -615,6 +678,7 @@ def SetPerGameData(self, games_data): break # one char per player slot from provider data self.StageResultsChanged() + self._EnforceBestOfLimit() StateManager.ReleaseSaving() def _SyncLastStageToStrike(self): diff --git a/src/TSHScoreboardWidget.py b/src/TSHScoreboardWidget.py index dab8d0f6f..bdccc3149 100644 --- a/src/TSHScoreboardWidget.py +++ b/src/TSHScoreboardWidget.py @@ -12,6 +12,7 @@ from .Helpers.TSHDirHelper import TSHResolve from .Helpers.TSHVersionHelper import add_beta_label from .Helpers.TSHBskyHelper import post_to_bsky +from .Workers import Worker from .TSHSelectSetWindow import TSHSelectSetWindow from .TSHSelectStationWindow import TSHSelectStationWindow @@ -24,6 +25,7 @@ from .TSHStatsUtil import TSHStatsUtil from .TSHHotkeys import TSHHotkeys from .TSHPlayerDB import TSHPlayerDB +from .TSHAppSignals import app_signals from .thumbnail import main_generate_thumbnail as thumbnail from .TSHThumbnailSettingsWidget import * @@ -156,7 +158,7 @@ def __init__(self, scoreboardNumber=1, *args): self.scrollArea.setStyleSheet( "QTabWidget::pane { margin: 0px,0px,0px,0px }") - self.layout().addWidget(self.scrollArea) + self.layout().addWidget(self.scrollArea, stretch=1) topOptions = QWidget() topOptions.setLayout(QHBoxLayout()) @@ -181,42 +183,67 @@ def __init__(self, scoreboardNumber=1, *args): col.layout().addWidget(self.playerNumber) self.playerNumber.valueChanged.connect(self.SetPlayersPerTeam) - # THUMBNAIL - col = QWidget() - col.setLayout(QVBoxLayout()) - col.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding) - col.setLayoutDirection(Qt.LayoutDirection.RightToLeft) - topOptions.layout().addWidget(col) + # Action buttons row (thumbnail, bluesky, start.gg, visibility) + actionRow = QWidget() + actionRow.setLayout(QHBoxLayout()) + actionRow.layout().setContentsMargins(0, 0, 0, 0) + actionRow.layout().setSpacing(4) + actionRow.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum) + topOptions.layout().addWidget(actionRow) if not SettingsManager.Get("general.disable_thumbnail_widget", False): self.thumbnailBtn = QPushButton( QApplication.translate("app", "Generate Thumbnail") + " ") self.thumbnailBtn.setIcon(QIcon('assets/icons/png_file.svg')) self.thumbnailBtn.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) - col.layout().addWidget(self.thumbnailBtn, Qt.AlignmentFlag.AlignRight) - # self.thumbnailBtn.setPopupMode(QToolButton.InstantPopup) self.thumbnailBtn.clicked.connect(self.GenerateThumbnail) - + actionRow.layout().addWidget(self.thumbnailBtn) + if SettingsManager.Get("bsky_account.enable_bluesky", True): self.bskyBtn = QPushButton( QApplication.translate("app", "Post to Bluesky") + " ") self.bskyBtn.setIcon(QIcon('assets/icons/bsky.svg')) self.bskyBtn.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) - col.layout().addWidget(self.bskyBtn, Qt.AlignmentFlag.AlignRight) self.bskyBtn.clicked.connect(self.PostToBsky) - - # VISIBILITY - col = QWidget() - col.setLayout(QVBoxLayout()) - col.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding) - col.setLayoutDirection(Qt.LayoutDirection.RightToLeft) - topOptions.layout().addWidget(col) + actionRow.layout().addWidget(self.bskyBtn) + + self.reportSetBtn = QPushButton( + QApplication.translate("app", "Report Set to Start.gg") + " ") + self.reportSetBtn.setIcon(QIcon('assets/icons/list.svg')) + self.reportSetBtn.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) + self.reportSetBtn.setVisible(False) + self.reportSetBtn.clicked.connect(self.ReportSetToStartGG) + actionRow.layout().addWidget(self.reportSetBtn) + + self.assignStreamWidget = QWidget() + self.assignStreamWidget.setLayout(QHBoxLayout()) + self.assignStreamWidget.layout().setContentsMargins(0, 0, 0, 0) + self.assignStreamWidget.layout().setSpacing(0) + self.assignStreamWidget.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) + self.assignStreamWidget.setVisible(False) + actionRow.layout().addWidget(self.assignStreamWidget) + + self.assignStreamBtn = QPushButton( + QApplication.translate("app", "Assign to Stream") + " ") + self.assignStreamBtn.setIcon(QIcon('assets/icons/station.svg')) + self.assignStreamBtn.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) + self.assignStreamBtn.setStyleSheet( + "QPushButton { border-top-right-radius: 0; border-bottom-right-radius: 0; }") + self.assignStreamWidget.layout().addWidget(self.assignStreamBtn) + self.assignStreamBtn.clicked.connect(self.AssignStreamToStartGG) + + self.assignStreamInput = QLineEdit() + self.assignStreamInput.setPlaceholderText( + QApplication.translate("app", "Twitch channel")) + self.assignStreamInput.setFixedWidth(130) + self.assignStreamInput.setStyleSheet( + "QLineEdit { border-top-left-radius: 0; border-bottom-left-radius: 0; }") + self.assignStreamWidget.layout().addWidget(self.assignStreamInput) self.eyeBt = QToolButton() self.eyeBt.setIcon(QIcon('assets/icons/eye.svg')) - self.eyeBt.setSizePolicy( - QSizePolicy.Maximum, QSizePolicy.Fixed) - col.layout().addWidget(self.eyeBt, Qt.AlignmentFlag.AlignRight) + self.eyeBt.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) + actionRow.layout().addWidget(self.eyeBt) self.eyeBt.setPopupMode(QToolButton.InstantPopup) menu = QMenu() self.eyeBt.setMenu(menu) @@ -252,88 +279,92 @@ def __init__(self, scoreboardNumber=1, *args): self.columns.setLayout(QHBoxLayout()) self.innerWidget.layout().addWidget(self.columns) + # --- Bottom bar: always visible, outside the scroll area --- bottomOptions = QWidget() bottomOptions.setLayout(QVBoxLayout()) - bottomOptions.layout().setContentsMargins(0, 0, 0, 0) - bottomOptions.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Maximum) - - self.innerWidget.layout().addWidget(bottomOptions) + bottomOptions.layout().setContentsMargins(4, 2, 4, 2) + bottomOptions.layout().setSpacing(2) + bottomOptions.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum) + self.layout().addWidget(bottomOptions) + # Stream URL as first row of bottom bar if SettingsManager.Get("bsky_account.enable_bluesky", True): - self.streamUrl = QHBoxLayout() + streamUrlRow = QHBoxLayout() self.streamUrlLabel = QLabel(QApplication.translate("app", "Stream URL") + " ") - self.streamUrl.layout().addWidget(self.streamUrlLabel) + streamUrlRow.addWidget(self.streamUrlLabel) self.streamUrlTextBox = QLineEdit() - self.streamUrl.layout().addWidget(self.streamUrlTextBox) + streamUrlRow.addWidget(self.streamUrlTextBox) self.streamUrlTextBox.editingFinished.connect( lambda element=self.streamUrlTextBox: StateManager.Set( f"score.{self.scoreboardNumber}.stream_url", element.text())) self.streamUrlTextBox.editingFinished.emit() - bottomOptions.layout().addLayout(self.streamUrl) + bottomOptions.layout().addLayout(streamUrlRow) + + # Set-loading buttons row + btnRow = QHBoxLayout() + btnRow.setSpacing(4) + bottomOptions.layout().addLayout(btnRow) self.btSelectSet = QPushButton( QApplication.translate("app", "Load set")) self.btSelectSet.setIcon(QIcon("./assets/icons/list.svg")) self.btSelectSet.setEnabled(False) - bottomOptions.layout().addWidget(self.btSelectSet) self.btSelectSet.clicked.connect(self.signals.SetSelection.emit) + btnRow.addWidget(self.btSelectSet) - hbox = QHBoxLayout() - bottomOptions.layout().addLayout(hbox) + btnRow.addSpacing(10) self.btLoadStationSet = QPushButton( - QApplication.translate("app", "Track sets from a stream or station")) + QApplication.translate("app", "Track stream/station")) self.btLoadStationSet.setIcon(QIcon("./assets/icons/station.svg")) - hbox.addWidget(self.btLoadStationSet) - self.btLoadStationSet.clicked.connect( - self.signals.StationSelection.emit) + self.btLoadStationSet.clicked.connect(self.signals.StationSelection.emit) + btnRow.addWidget(self.btLoadStationSet) - hbox = QHBoxLayout() - bottomOptions.layout().addLayout(hbox) + if self.scoreboardNumber <= 1 and not SettingsManager.Get("general.hide_track_player", False): + btnRow.addSpacing(10) - if self.scoreboardNumber <= 1 and not SettingsManager.Get("general.hide_track_player", False) : - self.btLoadPlayerSet = QPushButton("Load player set") - self.btLoadPlayerSet.setIcon( - QIcon("./assets/icons/person_search.svg")) + self.btLoadPlayerSet = QPushButton( + QApplication.translate("app", "Load player set")) + self.btLoadPlayerSet.setIcon(QIcon("./assets/icons/person_search.svg")) self.btLoadPlayerSet.setEnabled(False) - self.btLoadPlayerSet.clicked.connect( - self.signals.UserSetSelection.emit) - hbox.addWidget(self.btLoadPlayerSet) + self.btLoadPlayerSet.clicked.connect(self.signals.UserSetSelection.emit) + self.btLoadPlayerSet.setStyleSheet( + "QPushButton { border-top-right-radius: 0; border-bottom-right-radius: 0; }") + + self.btLoadPlayerSetOptions = QPushButton() + self.btLoadPlayerSetOptions.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum) + self.btLoadPlayerSetOptions.setIcon(QIcon("./assets/icons/settings.svg")) + self.btLoadPlayerSetOptions.clicked.connect(self.LoadUserSetOptionsClicked) + self.btLoadPlayerSetOptions.setStyleSheet( + "QPushButton { border-top-left-radius: 0; border-bottom-left-radius: 0; }") + + playerSetGroup = QWidget() + playerSetGroup.setLayout(QHBoxLayout()) + playerSetGroup.layout().setContentsMargins(0, 0, 0, 0) + playerSetGroup.layout().setSpacing(0) + playerSetGroup.layout().addWidget(self.btLoadPlayerSet) + playerSetGroup.layout().addWidget(self.btLoadPlayerSetOptions) + btnRow.addWidget(playerSetGroup) + TSHTournamentDataProvider.instance.signals.user_updated.connect( self.UpdateUserSetButton) TSHTournamentDataProvider.instance.signals.tournament_changed.connect( self.UpdateUserSetButton) - self.btLoadPlayerSetOptions = QPushButton() - self.btLoadPlayerSetOptions.setSizePolicy( - QSizePolicy.Maximum, QSizePolicy.Maximum) - self.btLoadPlayerSetOptions.setIcon( - QIcon("./assets/icons/settings.svg")) - self.btLoadPlayerSetOptions.clicked.connect( - self.LoadUserSetOptionsClicked) - hbox.addWidget(self.btLoadPlayerSetOptions) - + # Scoreboard link self.remoteScoreboardLabel = QApplication.translate( "app", "Open {0} in a browser to edit the scoreboard remotely." ).format(f"http://{self.GetIP()}:{SettingsManager.Get('general.webserver_port', 5500)}/scoreboard") self.remoteScoreboardLabel = add_beta_label(self.remoteScoreboardLabel, "web_score") self.remoteScoreboardLabel = QLabel(self.remoteScoreboardLabel) - self.remoteScoreboardLabel.setOpenExternalLinks(True) bottomOptions.layout().addWidget(self.remoteScoreboardLabel) - TSHTournamentDataProvider.instance.signals.tournament_changed.connect( - self.UpdateBottomButtons) - TSHTournamentDataProvider.instance.signals.tournament_changed.emit() - - self.selectSetWindow = TSHSelectSetWindow(self) - self.selectStationWindow = TSHSelectStationWindow(self) - + # Auto-update timer indicator self.timerLayout = QWidget() self.timerLayout.setLayout(QHBoxLayout()) self.timerLayout.layout().setContentsMargins(0, 0, 0, 0) - self.timerLayout.setSizePolicy( - QSizePolicy.Minimum, QSizePolicy.Maximum) + self.timerLayout.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Maximum) self.timerLayout.layout().setAlignment(Qt.AlignmentFlag.AlignCenter) bottomOptions.layout().addWidget(self.timerLayout) self.labelAutoUpdate = QLabel("Auto update") @@ -348,6 +379,13 @@ def __init__(self, scoreboardNumber=1, *args): self.timerLayout.layout().addWidget(self.timerCancelBt) self.timerLayout.setVisible(False) + TSHTournamentDataProvider.instance.signals.tournament_changed.connect( + self.UpdateBottomButtons) + TSHTournamentDataProvider.instance.signals.tournament_changed.emit() + + self.selectSetWindow = TSHSelectSetWindow(self) + self.selectStationWindow = TSHSelectStationWindow(self) + self.team1column = uic.loadUi(TSHResolve("src/layout/TSHScoreboardTeam.ui")) self.columns.layout().addWidget(self.team1column) self.team1column.findChild(QLabel, "teamLabel").setText( @@ -390,8 +428,9 @@ def __init__(self, scoreboardNumber=1, *args): self.team1column.findChild(QHBoxLayout, "horizontalLayout_2").layout().insertWidget(0, colorGroup1) self.team1column.findChild(QScrollArea).setWidget(QWidget()) - self.team1column.findChild( - QScrollArea).widget().setLayout(QVBoxLayout()) + _t1_layout = QVBoxLayout() + _t1_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.team1column.findChild(QScrollArea).widget().setLayout(_t1_layout) for c in self.team1column.findChildren(QLineEdit): c.editingFinished.connect( @@ -455,8 +494,9 @@ def __init__(self, scoreboardNumber=1, *args): self.team2column.findChild(QHBoxLayout, "horizontalLayout_2").layout().insertWidget(0, colorGroup2) self.team2column.findChild(QScrollArea).setWidget(QWidget()) - self.team2column.findChild( - QScrollArea).widget().setLayout(QVBoxLayout()) + _t2_layout = QVBoxLayout() + _t2_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.team2column.findChild(QScrollArea).widget().setLayout(_t2_layout) for c in self.team2column.findChildren(QLineEdit): c.editingFinished.connect( @@ -709,6 +749,62 @@ def PostToBsky(self): if os.path.exists(rm_path): os.remove(rm_path) + def ReportSetToStartGG(self, is_dq=False): + provider = TSHTournamentDataProvider.instance.provider + if not provider or not provider.SupportsSetReporting(): + return + + self.reportSetBtn.setEnabled(False) + self.reportSetBtn.setText(QApplication.translate("app", "Reporting...") + " ") + + def do_report(progress_callback=None, cancel_event=None): + return provider.ReportSet(self.scoreboardNumber, is_dq=is_dq) + + def on_result(result): + self.reportSetBtn.setEnabled(True) + self.reportSetBtn.setText(QApplication.translate("app", "Report Set to Start.gg") + " ") + if result.get("success"): + app_signals.status_message.emit(result.get("message", "Set reported successfully!"), False) + else: + msg = result.get("message", "Failed to report set") + logger.warning(f"Report set failed: {msg}") + app_signals.status_message.emit(f"Report set failed: {msg}", True) + + worker = Worker(do_report) + worker.signals.result.connect(on_result) + TSHTournamentDataProvider.instance.threadPool.start(worker) + + def AssignStreamToStartGG(self): + provider = TSHTournamentDataProvider.instance.provider + if not provider or not provider.SupportsStreamAssignment(): + return + + stream_name = self.assignStreamInput.text().strip() + if not stream_name: + app_signals.status_message.emit(QApplication.translate("app", "Enter a Twitch channel name first."), True) + return + + self.assignStreamBtn.setEnabled(False) + self.assignStreamBtn.setText(QApplication.translate("app", "Assigning...") + " ") + + def do_assign(progress_callback=None, cancel_event=None): + return provider.AssignStream(self.scoreboardNumber, stream_name) + + def on_result(result): + self.assignStreamBtn.setEnabled(True) + self.assignStreamBtn.setText(QApplication.translate("app", "Assign to Stream") + " ") + if result.get("success"): + logger.info(f"Assign stream: {result.get('message')}") + app_signals.status_message.emit(result.get("message", "Set assigned to stream!"), False) + else: + msg = result.get("message", "Failed to assign stream") + logger.warning(f"Assign stream failed: {msg}") + app_signals.status_message.emit(f"Assign stream failed: {msg}", True) + + worker = Worker(do_assign) + worker.signals.result.connect(on_result) + TSHTournamentDataProvider.instance.threadPool.start(worker) + def ToggleElements(self, action: QAction, elements): for pw in self.playerWidgets: for element in elements: @@ -734,6 +830,14 @@ def UpdateBottomButtons(self): self.btSelectSet.setEnabled(False) self.btLoadStationSet.setEnabled(False) + provider = TSHTournamentDataProvider.instance.provider + self.reportSetBtn.setVisible( + provider is not None and provider.SupportsSetReporting() + ) + self.assignStreamWidget.setVisible( + provider is not None and provider.SupportsStreamAssignment() + ) + def SetCharacterNumber(self, value): # logger.info(f"TSHScoreboardWidget#SetCharacterNumber({value})") for pw in self.playerWidgets: @@ -869,6 +973,7 @@ def SwapTeams(self): for p in self.playerWidgets: p.dataLock.acquire() + self.individualGameTracker._syncing_chars = True try: for i, p in enumerate(self.team1playerWidgets): p.SwapWith(self.team2playerWidgets[i]) @@ -904,6 +1009,7 @@ def SwapTeams(self): self.individualGameTracker.SwapStageResults() self.teamsSwapped = not self.teamsSwapped finally: + self.individualGameTracker._syncing_chars = False StateManager.Set( f"score.{self.scoreboardNumber}.teamsSwapped", self.teamsSwapped) @@ -1065,6 +1171,8 @@ def LoadSetClicked(self): def LoadStationSets(self, station): self.lastSetSelected = None self.lastStationSelected = station + if station and station.get("type") == "stream" and hasattr(self, "assignStreamInput"): + self.assignStreamInput.setText(station.get("identifier", "")) TSHTournamentDataProvider.instance.LoadStationSets(self) def LoadStationSetClicked(self): @@ -1347,6 +1455,10 @@ def ChangeSetData(self, data): if data.get("top_n"): StateManager.Set( f"score.{self.scoreboardNumber}.top_n", data.get("top_n")) + + if data.get("entrant1_id") is not None: + StateManager.Set(f"score.{self.scoreboardNumber}.entrant1_id", data.get("entrant1_id")) + StateManager.Set(f"score.{self.scoreboardNumber}.entrant2_id", data.get("entrant2_id")) finally: StateManager.ReleaseSaving() diff --git a/src/TSHStartGGSetReporter.py b/src/TSHStartGGSetReporter.py new file mode 100644 index 000000000..2ab500efa --- /dev/null +++ b/src/TSHStartGGSetReporter.py @@ -0,0 +1,264 @@ +import requests +import traceback +from loguru import logger +from .StateManager import StateManager +from .SettingsManager import SettingsManager +from .TSHGameAssetManager import TSHGameAssetManager + +STARTGG_GQL_ENDPOINT = "https://api.start.gg/gql/alpha" + +ASSIGN_STREAM_MUTATION = """ +mutation AssignStream($setId: ID!, $streamId: ID!) { + assignStream(setId: $setId, streamId: $streamId) { + id + state + } +} +""" + +REPORT_SET_MUTATION = """ +mutation ReportBracketSet($setId: ID!, $winnerId: ID!, $isDQ: Boolean, $gameData: [BracketSetGameDataInput]) { + reportBracketSet(setId: $setId, winnerId: $winnerId, isDQ: $isDQ, gameData: $gameData) { + id + state + } +} +""" + + +def _gql_request(api_token: str, query: str, variables: dict) -> dict: + response = requests.post( + STARTGG_GQL_ENDPOINT, + headers={ + "Authorization": f"Bearer {api_token}", + "Content-Type": "application/json", + }, + json={"query": query, "variables": variables}, + timeout=15, + ) + response.raise_for_status() + return response.json() + + +def AssignStream(set_id: str, stream_id: str) -> dict: + """ + Assign a Start.gg stream to the given set so it appears in the stream queue. + Returns a dict with keys: success (bool), message (str), data (dict or None). + """ + api_token = SettingsManager.Get("api_keys.startgg", "") + if not api_token: + return { + "success": False, + "message": "No Start.gg API token configured.\nGo to Settings → API Keys to add your token.", + "data": None, + } + + try: + variables = {"setId": str(set_id), "streamId": str(stream_id)} + logger.info(f"AssignStream GQL request: variables={variables}") + data = _gql_request(api_token, ASSIGN_STREAM_MUTATION, variables) + logger.info(f"AssignStream GQL response: {data}") + + errors = data.get("errors") + if errors: + messages = [e.get("message", str(e)) for e in errors] + hint = "" + if any("unknown" in m.lower() for m in messages): + hint = "\n\nThis usually means the stream is not registered for this event on Start.gg, or your token lacks TO permissions." + msg = "; ".join(messages) + return {"success": False, "message": f"Start.gg error: {msg}{hint}", "data": data} + + result = data.get("data", {}).get("assignStream") + if result: + return { + "success": True, + "message": f"Set assigned to stream successfully!\nSet ID: {result.get('id')} — State: {result.get('state')}", + "data": data, + } + + logger.warning(f"AssignStream: unexpected response structure: {data}") + return {"success": False, "message": "Unexpected response from Start.gg.", "data": data} + + except requests.exceptions.HTTPError as e: + logger.error(f"AssignStream HTTP error: {e}") + return {"success": False, "message": f"HTTP error: {e}", "data": None} + except Exception: + logger.error(f"AssignStream error:\n{traceback.format_exc()}") + return {"success": False, "message": f"Error assigning stream:\n{traceback.format_exc()}", "data": None} + + +def _get_character_startgg_id(char_data: dict) -> int | None: + # Prefer the ID baked in at model-build time (correct game-specific ID). + stored_id = char_data.get("startgg_character_id") + if stored_id: + return int(stored_id) + # Fallback for older state without the field. + codename = char_data.get("en_name") or char_data.get("codename") + if not codename: + return None + return TSHGameAssetManager.instance.GetStartGGIdFromCodename(codename) + + +def BuildGameData(scoreboard_number: int, team1_entrant_id: str, team2_entrant_id: str) -> list: + """ + Build per-game data list for the reportBracketSet mutation. + team1_entrant_id / team2_entrant_id are the START.GG entrant IDs for the + UI left (team-1) and right (team-2) sides respectively, with any team-swap + already resolved by the caller. + """ + best_of = StateManager.Get(f"score.{scoreboard_number}.best_of", 0) or 0 + wins_needed = (best_of // 2) + 1 if best_of > 0 else None + + games = [] + t1_wins = 0 + t2_wins = 0 + game_idx = 1 + while True: + if wins_needed and (t1_wins >= wins_needed or t2_wins >= wins_needed): + break + + stage = StateManager.Get(f"score.{scoreboard_number}.stages.{game_idx}") + if stage is None: + break + + t1_win = StateManager.Get(f"score.{scoreboard_number}.stages.{game_idx}.t1_win", False) + t2_win = StateManager.Get(f"score.{scoreboard_number}.stages.{game_idx}.t2_win", False) + + if t1_win: + t1_wins += 1 + elif t2_win: + t2_wins += 1 + + if not t1_win and not t2_win: + game_idx += 1 + continue + + winner_entrant_id = team1_entrant_id if t1_win else team2_entrant_id + + game_entry = { + "gameNum": game_idx, + "winnerId": winner_entrant_id, + } + + # Stage ID + if isinstance(stage, dict) and stage.get("smashgg_id"): + game_entry["stageId"] = stage["smashgg_id"] + + # Character selections — iterate both UI teams + selections = [] + for team_ui_idx, entrant_id in enumerate([team1_entrant_id, team2_entrant_id], start=1): + if not entrant_id: + continue + player_idx = 1 + while True: + char_slot_data = StateManager.Get( + f"score.{scoreboard_number}.stages.{game_idx}.team.{team_ui_idx}.player.{player_idx}.character" + ) + if char_slot_data is None: + break + for _, char_data in char_slot_data.items(): + if not char_data: + continue + char_id = _get_character_startgg_id(char_data) + if not char_id: + continue + selections.append({"entrantId": entrant_id, "characterId": char_id}) + player_idx += 1 + + if selections: + game_entry["selections"] = selections + + games.append(game_entry) + game_idx += 1 + + return games + + +def ReportSet( + scoreboard_number: int, + is_dq: bool = False, + set_id=None, + entrant1_id: str = None, + entrant2_id: str = None, +) -> dict: + """ + Report the current set result to Start.gg. + set_id / entrant1_id / entrant2_id can be passed directly (e.g. after name-based resolution); + if omitted they are read from StateManager. + Returns a dict with keys: success (bool), message (str), data (dict or None). + """ + api_token = SettingsManager.Get("api_keys.startgg", "") + if not api_token: + return { + "success": False, + "message": "No Start.gg API token configured.\nGo to Settings → API Keys to add your token.", + "data": None, + } + + if set_id is None: + set_id = StateManager.Get(f"score.{scoreboard_number}.set_id") + if entrant1_id is None: + entrant1_id = StateManager.Get(f"score.{scoreboard_number}.entrant1_id", "") or "" + if entrant2_id is None: + entrant2_id = StateManager.Get(f"score.{scoreboard_number}.entrant2_id", "") or "" + + teams_swapped = StateManager.Get(f"score.{scoreboard_number}.teamsSwapped", False) + + # Map UI positions to entrant IDs + if not teams_swapped: + ui_team1_entrant = entrant1_id + ui_team2_entrant = entrant2_id + else: + ui_team1_entrant = entrant2_id + ui_team2_entrant = entrant1_id + + team1_score = StateManager.Get(f"score.{scoreboard_number}.team.1.score", 0) or 0 + team2_score = StateManager.Get(f"score.{scoreboard_number}.team.2.score", 0) or 0 + + if team1_score == team2_score and not is_dq: + return {"success": False, "message": "Scores are tied. Cannot report without a winner.", "data": None} + + winner_entrant_id = ui_team1_entrant if team1_score > team2_score else ui_team2_entrant + + if not winner_entrant_id: + return { + "success": False, + "message": "Could not determine winner entrant ID.\nMake sure the set was loaded from Start.gg.", + "data": None, + } + + game_data = BuildGameData(scoreboard_number, ui_team1_entrant, ui_team2_entrant) + + variables = { + "setId": str(set_id), + "winnerId": str(winner_entrant_id), + "isDQ": is_dq, + } + if game_data: + variables["gameData"] = game_data + + try: + data = _gql_request(api_token, REPORT_SET_MUTATION, variables) + + errors = data.get("errors") + if errors: + msg = "; ".join(e.get("message", str(e)) for e in errors) + return {"success": False, "message": f"Start.gg API error:\n{msg}", "data": data} + + result = data.get("data", {}).get("reportBracketSet") + if result: + reported = result[0] if isinstance(result, list) else result + return { + "success": True, + "message": f"Set reported successfully!\nSet ID: {reported.get('id')} — State: {reported.get('state')}", + "data": data, + } + + return {"success": False, "message": "Unexpected response from Start.gg.", "data": data} + + except requests.exceptions.HTTPError as e: + logger.error(f"TSHStartGGSetReporter HTTP error: {e}") + return {"success": False, "message": f"HTTP error: {e}", "data": None} + except Exception: + logger.error(f"TSHStartGGSetReporter error:\n{traceback.format_exc()}") + return {"success": False, "message": f"Error reporting set:\n{traceback.format_exc()}", "data": None} diff --git a/src/TournamentDataProvider/StartGGDataProvider.py b/src/TournamentDataProvider/StartGGDataProvider.py index 0e8007d71..57aec2c52 100644 --- a/src/TournamentDataProvider/StartGGDataProvider.py +++ b/src/TournamentDataProvider/StartGGDataProvider.py @@ -759,6 +759,8 @@ def ParseMatchDataNewApi(self, _set): # Per-game data (stage + characters + winner per game) entrant1_id = str(p1.get("entrant", {}).get("id", "")) if p1.get("entrant") else "" entrant2_id = str(p2.get("entrant", {}).get("id", "")) if p2.get("entrant") else "" + setData["entrant1_id"] = entrant1_id + setData["entrant2_id"] = entrant2_id raw_games = _set.get("games") or [] games_data = [] for game in sorted(raw_games, key=lambda g: g.get("orderNum") or 0): @@ -2126,6 +2128,169 @@ def GetRealEventURL(self, url): self.url = url return url + def _has_api_token(self) -> bool: + from ..SettingsManager import SettingsManager + return bool(SettingsManager.Get("api_keys.startgg", "")) + + def SupportsSetReporting(self) -> bool: + return self._has_api_token() + + def _get_player_names_from_state(self, scoreboard_number: int, team: int) -> list: + from ..StateManager import StateManager + names = [] + for p in range(1, 5): + name = StateManager.Get(f"score.{scoreboard_number}.team.{team}.player.{p}.mergedOnlyName", "") + if name: + names.append(name.removesuffix(" [L]").strip()) + return [n for n in names if n] + + @staticmethod + def _name_matches(entrant_name: str, player_names: list) -> bool: + e = (entrant_name or "").strip().lower() + for tag in player_names: + t = (tag or "").strip().lower() + if not t: + continue + if e == t or e.endswith(f"| {t}") or e.endswith(f"|{t}"): + return True + return False + + def FindSetByPlayerNames(self, names_team1: list, names_team2: list) -> list: + """ + Search open sets for one(s) matching the given player name lists. + Returns list of matching parsed set dicts (each has id, entrant1_id, entrant2_id, p1_name, p2_name). + """ + open_sets = self.GetMatches(getFinished=False) or [] + matches = [] + for s in open_sets: + p1 = (s.get("p1_name") or "").strip() + p2 = (s.get("p2_name") or "").strip() + if not p1 or not p2: + continue + if (self._name_matches(p1, names_team1) and self._name_matches(p2, names_team2)) or \ + (self._name_matches(p1, names_team2) and self._name_matches(p2, names_team1)): + matches.append(s) + return matches + + def ReportSet(self, scoreboard_number: int, is_dq: bool = False) -> dict: + from ..TSHStartGGSetReporter import ReportSet as _report + from ..StateManager import StateManager + + set_id = StateManager.Get(f"score.{scoreboard_number}.set_id") + entrant1_id = StateManager.Get(f"score.{scoreboard_number}.entrant1_id", "") or "" + entrant2_id = StateManager.Get(f"score.{scoreboard_number}.entrant2_id", "") or "" + + if not set_id or not entrant1_id: + names_t1 = self._get_player_names_from_state(scoreboard_number, 1) + names_t2 = self._get_player_names_from_state(scoreboard_number, 2) + + if not names_t1 or not names_t2: + return { + "success": False, + "message": "No set loaded and no player names found in the scoreboard.", + "data": None, + } + + logger.info(f"Resolving set by player names: {names_t1} vs {names_t2}") + matches = self.FindSetByPlayerNames(names_t1, names_t2) + + if not matches: + return { + "success": False, + "message": f"No open set found matching \"{names_t1[0]}\" vs \"{names_t2[0]}\" on Start.gg.\n" + "Check that both players are registered and their set is not already completed.", + "data": None, + } + + if len(matches) > 1: + lines = "\n".join(f" • {s.get('p1_name')} vs {s.get('p2_name')}" for s in matches[:5]) + return { + "success": False, + "message": f"Multiple open sets found for those players — load the specific set first:\n{lines}", + "data": None, + } + + found = matches[0] + set_id = found.get("id") + startgg_e1 = found.get("entrant1_id", "") + startgg_e2 = found.get("entrant2_id", "") + logger.info(f"Resolved set {set_id}: {found.get('p1_name')} vs {found.get('p2_name')}") + + # Align entrant IDs with the current UI ordering. + # _report treats entrant1_id as "the player on UI team 1 when not swapped", + # so account for both the Start.gg name ordering and any UI team swap. + teams_swapped = StateManager.Get(f"score.{scoreboard_number}.teamsSwapped", False) + p1_is_ui_t1 = self._name_matches(found.get("p1_name", ""), names_t1) + ui_t1 = startgg_e1 if p1_is_ui_t1 else startgg_e2 + ui_t2 = startgg_e2 if p1_is_ui_t1 else startgg_e1 + if not teams_swapped: + entrant1_id, entrant2_id = ui_t1, ui_t2 + else: + entrant1_id, entrant2_id = ui_t2, ui_t1 + + return _report(scoreboard_number, is_dq, set_id=set_id, entrant1_id=entrant1_id, entrant2_id=entrant2_id) + + def SupportsStreamAssignment(self) -> bool: + return self._has_api_token() + + def AssignStream(self, scoreboard_number: int, stream_name: str) -> dict: + from ..TSHStartGGSetReporter import AssignStream as _assign + from ..StateManager import StateManager + + logger.info(f"AssignStream: stream_name={stream_name!r}") + + # Resolve set ID — from state or by player names + set_id = StateManager.Get(f"score.{scoreboard_number}.set_id") + if not set_id: + names_t1 = self._get_player_names_from_state(scoreboard_number, 1) + names_t2 = self._get_player_names_from_state(scoreboard_number, 2) + logger.info(f"AssignStream: no set_id in state, resolving by names: {names_t1} vs {names_t2}") + if not names_t1 or not names_t2: + return {"success": False, "message": "No set loaded and no player names found in the scoreboard.", "data": None} + + matches = self.FindSetByPlayerNames(names_t1, names_t2) + logger.info(f"AssignStream: FindSetByPlayerNames found {len(matches)} match(es): {[(s.get('p1_name'), s.get('p2_name'), s.get('id')) for s in matches]}") + if not matches: + return { + "success": False, + "message": f"No open set found matching \"{names_t1[0]}\" vs \"{names_t2[0]}\" on Start.gg.\n" + "Check that both players are registered and their set is not already completed.", + "data": None, + } + if len(matches) > 1: + lines = "\n".join(f" • {s.get('p1_name')} vs {s.get('p2_name')}" for s in matches[:5]) + return { + "success": False, + "message": f"Multiple open sets found for those players — load the specific set first:\n{lines}", + "data": None, + } + set_id = matches[0].get("id") + logger.info(f"AssignStream: resolved set_id={set_id} ({matches[0].get('p1_name')} vs {matches[0].get('p2_name')})") + else: + logger.info(f"AssignStream: using set_id={set_id} from state") + + # Resolve stream ID by name + stations = self.GetStations() or [] + available_streams = [s for s in stations if s.get("type") == "stream"] + logger.info(f"AssignStream: available streams: {[(s.get('identifier'), s.get('id')) for s in available_streams]}") + + stream = next( + (s for s in available_streams + if str(s.get("identifier", "")).lower() == stream_name.strip().lower()), + None + ) + if not stream: + logger.warning(f"AssignStream: stream {stream_name!r} not found among {[s.get('identifier') for s in available_streams]}") + return { + "success": False, + "message": f"Stream \"{stream_name}\" not found in this tournament's stream list.\n" + f"Available streams: {', '.join(s.get('identifier', '?') for s in available_streams) or 'none'}", + "data": None, + } + + logger.info(f"AssignStream: calling assignStream(setId={set_id}, streamId={stream.get('id')})") + return _assign(str(set_id), str(stream.get("id"))) + sggTdpDir = TSHResolve('src/TournamentDataProvider') def readQueryFile(tdpdir, filename): diff --git a/src/TournamentDataProvider/StartGGSetsQuery.txt b/src/TournamentDataProvider/StartGGSetsQuery.txt index 3f5c59a76..b05be4218 100644 --- a/src/TournamentDataProvider/StartGGSetsQuery.txt +++ b/src/TournamentDataProvider/StartGGSetsQuery.txt @@ -21,6 +21,7 @@ query EventMatchListQuery($eventSlug: String!, $page: Int = 1, $filters: SetFilt } slots { entrant { + id name participants { player { diff --git a/src/TournamentDataProvider/TournamentDataProvider.py b/src/TournamentDataProvider/TournamentDataProvider.py index 0ae7da364..30b382251 100644 --- a/src/TournamentDataProvider/TournamentDataProvider.py +++ b/src/TournamentDataProvider/TournamentDataProvider.py @@ -84,6 +84,19 @@ def GetFutureMatch(self, progrss_callback=None): def GetFutureMatchesList(self, sets: object, progress_callback=None, cancel_event=None): pass + def SupportsSetReporting(self) -> bool: + return False + + def ReportSet(self, scoreboard_number: int, is_dq: bool = False) -> dict: + return {"success": False, "message": "Set reporting is not supported by this provider."} + + def SupportsStreamAssignment(self) -> bool: + return False + + def AssignStream(self, scoreboard_number: int, stream_name: str) -> dict: + """Assign a stream to the current set by stream name. Provider resolves set and stream name → ID.""" + return {"success": False, "message": "Stream assignment is not supported by this provider."} + def EnrichPlayerData(self, playerData): # Hook for providers to lazily fill in fields when a player is # loaded into a slot (called from TSHScoreboardPlayerWidget.SetData diff --git a/src/TournamentStreamHelper.py b/src/TournamentStreamHelper.py index 62f061d07..ec448b4db 100755 --- a/src/TournamentStreamHelper.py +++ b/src/TournamentStreamHelper.py @@ -143,12 +143,23 @@ def catchtime(msg = ''): from .Helpers.TSHCountryHelper import TSHCountryHelper from .Helpers.TSHControllerHelper import TSHControllerHelper from .TSHScoreboardManager import TSHScoreboardManager +from .TSHAppSignals import app_signals from src.TSHAssetDownloader import TSHAssetDownloader from src.TSHAboutWidget import TSHAboutWidget from .TSHScoreboardStageWidget import TSHScoreboardStageWidget from src.TSHWebServer import WebServer # autopep8: on + +def _lang_flag(code: str) -> str: + """Return flag emoji for a BCP-47 locale code like 'en-US' or 'pt-BR'.""" + parts = code.split("-") + country = parts[-1].upper() if len(parts) >= 2 else "" + if len(country) != 2: + return "" + return "".join(chr(0x1F1E6 + ord(c) - ord("A")) for c in country) + + def DownloadLayoutsOnBoot(): """ Downloads latest layouts from Github if the folder is empty @@ -391,6 +402,9 @@ def __init__(self, loop=None): self.setCentralWidget(central_widget) central_widget.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum) + self.statusBar().setSizeGripEnabled(False) + app_signals.status_message.connect(self._show_status) + self.dockWidgets = [] if not SettingsManager.Get("general.disable_thumbnail_widget", False): @@ -483,40 +497,54 @@ def __init__(self, loop=None): group_box = QWidget() group_box.setLayout(QVBoxLayout()) - group_box.setSizePolicy( - QSizePolicy.Minimum, QSizePolicy.Maximum) + group_box.layout().setContentsMargins(0, 0, 0, 0) + group_box.layout().setSpacing(2) + group_box.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum) base_layout.layout().addWidget(group_box) - # Set tournament - hbox = QHBoxLayout() - group_box.layout().addLayout(hbox) + # Row 1: tournament-related buttons (groups separated by spacing) + row1 = QHBoxLayout() + row1.setSpacing(2) + group_box.layout().addLayout(row1) + + def _connected_group(*widgets): + """Wrap widgets in a zero-spacing container and apply flat shared-border styling.""" + w = QWidget() + w.setLayout(QHBoxLayout()) + w.layout().setContentsMargins(0, 0, 0, 0) + w.layout().setSpacing(0) + for i, btn in enumerate(widgets): + is_first = i == 0 + is_last = i == len(widgets) - 1 + radii = [] + if not is_first: + radii += ["border-top-left-radius: 0", "border-bottom-left-radius: 0"] + if not is_last: + radii += ["border-top-right-radius: 0", "border-bottom-right-radius: 0"] + if radii: + btn.setStyleSheet(f"{type(btn).__name__} {{ {'; '.join(radii)}; }}") + w.layout().addWidget(btn) + return w self.setTournamentBt = QPushButton( QApplication.translate("app", "Set tournament")) - hbox.addWidget(self.setTournamentBt) self.setTournamentBt.clicked.connect( lambda bt=None, s=self: TSHTournamentDataProvider.instance.SetStartggEventSlug(s)) self.unsetTournamentBt = QPushButton() - self.unsetTournamentBt.setSizePolicy( - QSizePolicy.Maximum, QSizePolicy.Maximum) + self.unsetTournamentBt.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum) self.unsetTournamentBt.setIcon(QIcon("./assets/icons/cancel.svg")) self.unsetTournamentBt.clicked.connect(lambda: [ TSHTournamentDataProvider.instance.SetTournament(None) ]) - hbox.addWidget(self.unsetTournamentBt) + row1.addWidget(_connected_group(self.setTournamentBt, self.unsetTournamentBt)) - # Follow startgg user if not SettingsManager.Get("general.hide_track_player", False): - hbox = QHBoxLayout() - group_box.layout().addLayout(hbox) - + row1.addSpacing(10) self.btLoadPlayerSet = QPushButton( - QApplication.translate("app", "Load tournament and sets from StartGG user")) + QApplication.translate("app", "Load from StartGG user")) self.btLoadPlayerSet.setIcon(QIcon("./assets/icons/startgg.svg")) self.btLoadPlayerSet.clicked.connect(self.LoadUserSetClicked) - self.btLoadPlayerSet.setIcon(QIcon("./assets/icons/startgg.svg")) - hbox.addWidget(self.btLoadPlayerSet) TSHTournamentDataProvider.instance.signals.user_updated.connect( self.UpdateUserSetButton) @@ -524,24 +552,19 @@ def __init__(self, loop=None): self.UpdateUserSetButton) self.btLoadPlayerSetOptions = QPushButton() - self.btLoadPlayerSetOptions.setSizePolicy( - QSizePolicy.Maximum, QSizePolicy.Maximum) - self.btLoadPlayerSetOptions.setIcon( - QIcon("./assets/icons/settings.svg")) - self.btLoadPlayerSetOptions.clicked.connect( - self.LoadUserSetOptionsClicked) - hbox.addWidget(self.btLoadPlayerSetOptions) + self.btLoadPlayerSetOptions.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum) + self.btLoadPlayerSetOptions.setIcon(QIcon("./assets/icons/settings.svg")) + self.btLoadPlayerSetOptions.clicked.connect(self.LoadUserSetOptionsClicked) + row1.addWidget(_connected_group(self.btLoadPlayerSet, self.btLoadPlayerSetOptions)) self.UpdateUserSetButton() - - # Completed Sets Feature - hbox = QHBoxLayout() - group_box.layout().addLayout(hbox) + + row1.addSpacing(10) self.btPullCompletedSets = QPushButton( - QApplication.translate("app", "Pull Latest Completed Sets from StartGG")) + QApplication.translate("app", "Pull Completed Sets")) self.btPullCompletedSets.setIcon(QIcon("./assets/icons/startgg.svg")) self.btPullCompletedSets.clicked.connect(TSHTournamentDataProvider.instance.GetCompletedSets) - hbox.addWidget(self.btPullCompletedSets) + row1.addWidget(self.btPullCompletedSets) # label_margin = " "*18 # label = QLabel( # label_margin + QApplication.translate("app", "Time until Completed Sets refresh:") + " ") @@ -641,7 +664,7 @@ def __init__(self, loop=None): QApplication.translate("app", "Program language changed successfully.")) action = languageSelect.addAction( - QApplication.translate("app", "System language")) + "🌐 " + QApplication.translate("app", "System language")) languageSelectGroup.addAction(action) action.setCheckable(True) action.setChecked(True) @@ -651,7 +674,8 @@ def __init__(self, loop=None): ]) for code, language in TSHLocaleHelper.languages.items(): - action = languageSelect.addAction(f"{language[0]} / {language[1]}") + flag = _lang_flag(code) + action = languageSelect.addAction(f"{flag} {language[0]} / {language[1]}") action.setCheckable(True) languageSelectGroup.addAction(action) action.triggered.connect(lambda x=None, c=code: [ @@ -672,7 +696,7 @@ def __init__(self, loop=None): QApplication.translate("app", "Game Asset Language changed successfully.")) action = languageSelect.addAction( - QApplication.translate("app", "Same as program language")) + "🌐 " + QApplication.translate("app", "Same as program language")) languageSelectGroup.addAction(action) action.setCheckable(True) action.setChecked(True) @@ -682,7 +706,8 @@ def __init__(self, loop=None): ]) for code, language in TSHLocaleHelper.languages.items(): - action = languageSelect.addAction(f"{language[0]} / {language[1]}") + flag = _lang_flag(code) + action = languageSelect.addAction(f"{flag} {language[0]} / {language[1]}") action.setCheckable(True) languageSelectGroup.addAction(action) action.triggered.connect(lambda x=None, c=code: [ @@ -703,7 +728,7 @@ def __init__(self, loop=None): QApplication.translate("app", "Tournament term language changed successfully.")) action = languageSelect.addAction( - QApplication.translate("app", "Same as program language")) + "🌐 " + QApplication.translate("app", "Same as program language")) languageSelectGroup.addAction(action) action.setCheckable(True) action.setChecked(True) @@ -713,7 +738,8 @@ def __init__(self, loop=None): ]) for code, language in TSHLocaleHelper.languages.items(): - action = languageSelect.addAction(f"{language[0]} / {language[1]}") + flag = _lang_flag(code) + action = languageSelect.addAction(f"{flag} {language[0]} / {language[1]}") action.setCheckable(True) languageSelectGroup.addAction(action) action.triggered.connect(lambda x=None, c=code: [ @@ -792,8 +818,9 @@ def __init__(self, loop=None): action.setIcon(QIcon('assets/icons/info.svg')) action.triggered.connect(lambda: self.aboutWidget.show()) - # Game Select and Scoreboard Count + # Row 2: game select and scoreboard count hbox = QHBoxLayout() + hbox.setSpacing(4) group_box.layout().addLayout(hbox) self.gameSelect = QComboBox() @@ -806,6 +833,7 @@ def __init__(self, loop=None): self.gameSelect.model().setParent(proxyModel) self.gameSelect.setModel(proxyModel) self.gameSelect.setFont(self.font_small) + self.gameSelect.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.gameSelect.activated.connect( lambda x: TSHGameAssetManager.instance.LoadGameAssets(self.gameSelect.currentData())) TSHGameAssetManager.instance.signals.onLoad.connect( @@ -873,8 +901,18 @@ def _on_game_load(): ) pre_base_layout.addLayout(base_layout) - hbox.addWidget(self.gameSelect) - hbox.addWidget(self.gameReloadBtn) + self.gameSelect.setStyleSheet( + "QComboBox { border-top-right-radius: 0; border-bottom-right-radius: 0; }") + self.gameReloadBtn.setStyleSheet( + "QPushButton { border-top-left-radius: 0; border-bottom-left-radius: 0; }") + gameSelectGroup = QWidget() + gameSelectGroup.setLayout(QHBoxLayout()) + gameSelectGroup.layout().setContentsMargins(0, 0, 0, 0) + gameSelectGroup.layout().setSpacing(0) + gameSelectGroup.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + gameSelectGroup.layout().addWidget(self.gameSelect) + gameSelectGroup.layout().addWidget(self.gameReloadBtn) + hbox.addWidget(gameSelectGroup) hbox.addWidget(self.moddedContentWidget) self.scoreboardAmount = QSpinBox() @@ -900,6 +938,7 @@ def _on_game_load(): QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum) self.btLoadModifyTabName.clicked.connect(self.ChangeTab) + hbox.addSpacing(10) hbox.addWidget(label) hbox.addWidget(self.scoreboardAmount) hbox.addWidget(self.btLoadModifyTabName) @@ -1054,6 +1093,15 @@ def LoadUserSetOptionsClicked(self): parrygg=(provider_name == "ParryGG"), ) + def _show_status(self, message: str, is_error: bool): + bar = self.statusBar() + if is_error: + bar.setStyleSheet("QStatusBar { color: #ff6b6b; }") + bar.showMessage(message) + else: + bar.setStyleSheet("") + bar.showMessage(message, 5000) + def closeEvent(self, event): logger.info("Shutting down...") self.qtSettings.setValue("geometry", self.saveGeometry())