Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Contributing to TournamentStreamHelper

## Tech Stack Overview

| Layer | Technology |
|-------|-----------|
| Desktop UI | Python 3.10+ with PyQt6 |
| UI layout files | Qt Designer XML (`.ui` files) |
| State management | `StateManager.py` (custom, signal-based) |
| Web component | React 19 + Redux (stage strike feature only) |
| Executable build | PyInstaller |

---

## Setting Up the Dev Environment

### Prerequisites

- Python 3.10+
- Qt Designer (bundled with `pyqt6-tools`, or standalone via Qt installation)

### Install dependencies

```bash
pip install -r dependencies/requirements.txt
```

### Run the app

```bash
python main.py
```

You do **not** need to compile the executable to develop or test — running from Python is the standard dev workflow.

---

## Project Structure

```
TournamentStreamHelper/
├── main.py # Entry point
├── src/
│ ├── layout/ # Qt Designer .ui files (UI definitions)
│ │ ├── TSHTournamentInfo.ui
│ │ ├── TSHScoreboardPlayer.ui
│ │ ├── TSHCommentary.ui
│ │ └── ...
│ ├── TSHTournamentInfoWidget.py # Python widget classes
│ ├── TSHScoreboardPlayerWidget.py
│ ├── TSHCommentaryWidget.py
│ ├── StateManager.py # Central state (read/write/signals)
│ └── ...
├── assets/ # Icons, characters, rulesets, etc.
├── stage_strike_app/ # React web app (stage strike only)
├── dependencies/
│ ├── requirements.txt # Python dependencies
│ └── tsh.spec # PyInstaller build spec
└── .github/workflows/ # CI/CD (build + release)
```

The naming convention is consistent: `TSHFoo.ui` is loaded and managed by `TSHFooWidget.py`.

---

## Adding a New UI Field

### Step 1 — Edit the `.ui` file in Qt Designer

1. Open the relevant `src/layout/TSH*.ui` file in Qt Designer.
2. Drag in a new widget (e.g. `QLineEdit`, `QSpinBox`, `QCheckBox`).
3. Set a descriptive `objectName` in the Properties panel (e.g. `myNewField`).
4. Save the file.

### Step 2 — Wire the widget up in the Python class

Open the matching `src/TSH*Widget.py` file. Follow the pattern of existing fields:

**Reading state on load:**
```python
self.ui.myNewField.setText(StateManager.get_state("my_new_field") or "")
```

**Saving state on change:**
```python
self.ui.myNewField.editingFinished.connect(
lambda: StateManager.set_state("my_new_field", self.ui.myNewField.text())
)
```

Common signal-to-use mapping:

| Widget | Signal | Value accessor |
|--------|--------|----------------|
| `QLineEdit` | `editingFinished` | `.text()` |
| `QSpinBox` | `valueChanged` | `.value()` |
| `QCheckBox` | `stateChanged` | `.isChecked()` |
| `QDateEdit` | `dateChanged` | `.date().toString(...)` |
| `QPushButton` | `clicked` | — |

### Step 3 — Expose the value to layouts (optional)

If the field value should be available to OBS browser source overlays, add the state key to the output in the relevant scoreboard/state export logic. Search for similar keys in `StateManager.py` to find the right place.

---

## Building the Executable

> For pull request contributions, you typically don't need to build the executable — maintainers handle releases via CI.

If you need to build locally:

```bash
# 1. Generate Qt translation files (requires pyside6-lrelease)
pyside6-lrelease <path/to/*.ts files>

# 2. Build the executable
pyinstaller --noconfirm ./dependencies/tsh.spec

# Output: dist/TSH.exe
```

The CI pipeline (`.github/workflows/build_app.yml`) runs these steps automatically on push.

---

## Making a Pull Request

1. Fork the repository and create a branch from `main`.
2. Make your changes and test locally with `python main.py`.
3. Keep commits focused — one logical change per commit.
4. Open a PR against `main` with a clear description of what the change does and why.

---

## Tips

- The `StateManager` is the single source of truth. All UI fields should read from and write to it — never store widget state separately.
- `.ui` files are XML and can be diff'd normally in PRs; prefer Qt Designer over hand-editing them.
- The `stage_strike_app/` React app has its own `package.json` and is built separately with `npm run build` if you need to modify the stage strike web UI.
1 change: 1 addition & 0 deletions dependencies/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ unidic-lite
pypinyin
koroman==1.0.14
arabic_buckwalter_transliteration
openpyxl
2 changes: 1 addition & 1 deletion layout
Submodule layout updated 326 files
105 changes: 105 additions & 0 deletions src/TSHScoreboardWidget.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import math
import os
import platform
import socket
import subprocess
from datetime import datetime

from qtpy.QtGui import *
from qtpy.QtWidgets import *
Expand Down Expand Up @@ -572,6 +574,11 @@ def __init__(self, scoreboardNumber=1, *args):
self.scoreColumn.findChild(
QPushButton, "btResetScore").setIcon(QIcon('assets/icons/undo.svg'))

self.scoreColumn.findChild(
QPushButton, "btExportScore").clicked.connect(self.ExportScore)
self.scoreColumn.findChild(
QPushButton, "btExportScore").setIcon(QIcon('assets/icons/save.svg'))

# Add default and user tournament phase title files
self.scoreColumn.findChild(QComboBox, "phase").addItem("")
TSHLocaleHelper.LoadPhaseNamesToWidget(self.scoreColumn.findChild(QComboBox, "phase"))
Expand Down Expand Up @@ -925,6 +932,104 @@ def ResetScore(self):
carry_stage_codename=carry_stage
)

def ExportScore(self):
import openpyxl
from openpyxl.styles import PatternFill, Font

HEADERS = [
"Timestamp", "Phase", "Match",
"P1 Sponsor", "P1 Country", "P1 Characters", "P1 Name", "P1 Score",
"P2 Score", "P2 Name", "P2 Characters", "P2 Country", "P2 Sponsor",
]
# Column ranges for each team (1-based), used for coloring
P1_COLS = range(4, 9) # P1 Sponsor → P1 Score
P2_COLS = range(9, 14) # P2 Score → P2 Characters

GREEN = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
RED = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")

out_path = "./out/score_export.xlsx"
os.makedirs(os.path.dirname(out_path), exist_ok=True)

if os.path.exists(out_path):
wb = openpyxl.load_workbook(out_path)
ws = wb.active
else:
wb = openpyxl.Workbook()
ws = wb.active
ws.append(HEADERS)
for cell in ws[1]:
cell.font = Font(bold=True)

def player_fields(widgets):
names, sponsors, countries, chars = [], [], [], []
for p in widgets:
names.append(StateManager.Get(f"{p.path}.name", "") or "")
sponsors.append(StateManager.Get(f"{p.path}.team", "") or "")
country_data = StateManager.Get(f"{p.path}.country", None)
if isinstance(country_data, dict):
countries.append(country_data.get("en_name") or country_data.get("name", ""))
else:
countries.append("")
characters = StateManager.Get(f"{p.path}.character", {}) or {}
char_names = [
v.get("name", "") for v in characters.values()
if isinstance(v, dict) and v.get("name")
]
chars.append(", ".join(char_names))
sep = " / "
return sep.join(sponsors), sep.join(names), sep.join(countries), sep.join(chars)

phase = StateManager.Get(f"score.{self.scoreboardNumber}.phase", "") or ""
match = StateManager.Get(f"score.{self.scoreboardNumber}.match", "") or ""
p1_score = StateManager.Get(f"score.{self.scoreboardNumber}.team.1.score", 0)
p2_score = StateManager.Get(f"score.{self.scoreboardNumber}.team.2.score", 0)

p1_sponsor, p1_name, p1_country, p1_chars = player_fields(self.team1playerWidgets)
p2_sponsor, p2_name, p2_country, p2_chars = player_fields(self.team2playerWidgets)

new_row = [
datetime.now().strftime("%Y-%m-%d %H:%M:%S"), phase, match,
p1_sponsor, p1_country, p1_chars, p1_name, p1_score,
p2_score, p2_name, p2_chars, p2_country, p2_sponsor,
]

# Skip export if all fields (except timestamp) match the last saved row
if ws.max_row > 1:
last_row = [ws.cell(row=ws.max_row, column=c).value for c in range(1, len(HEADERS) + 1)]
if last_row[1:] == new_row[1:]:
msgBox = QMessageBox()
msgBox.setWindowIcon(QIcon('assets/icons/icon.png'))
msgBox.setWindowTitle(QApplication.translate("app", "TSH - Export Score"))
msgBox.setText(QApplication.translate("app", "No changes since last export. Skipping."))
msgBox.setIcon(QMessageBox.Icon.Information)
msgBox.exec()
return

ws.append(new_row)

# Color winner green, loser red
row = ws.max_row
if p1_score != p2_score:
winner_cols, loser_cols = (P1_COLS, P2_COLS) if p1_score > p2_score else (P2_COLS, P1_COLS)
for col in winner_cols:
ws.cell(row=row, column=col).fill = GREEN
for col in loser_cols:
ws.cell(row=row, column=col).fill = RED

wb.save(out_path)

msgBox = QMessageBox()
msgBox.setWindowIcon(QIcon('assets/icons/icon.png'))
msgBox.setWindowTitle(QApplication.translate("app", "TSH - Export Score"))
msgBox.setText(
QApplication.translate("app", "Score exported to {0}").format(
os.path.abspath(out_path)
)
)
msgBox.setIcon(QMessageBox.Icon.NoIcon)
msgBox.exec()

def AutoUpdate(self, data):
TSHTournamentDataProvider.instance.GetMatch(
self, data.get("id"), overwrite=False)
Expand Down
7 changes: 7 additions & 0 deletions src/layout/TSHScoreboardScore.ui
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,13 @@
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="btExportScore">
<property name="text">
<string>EXPORT SCORE</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
Expand Down