diff options
| author | Schark <jordan@schark.online> | 2026-03-25 23:22:28 -0400 |
|---|---|---|
| committer | Schark <jordan@schark.online> | 2026-03-25 23:22:28 -0400 |
| commit | 028b6a80e473252bfce2d6aaf664f5af8949b760 (patch) | |
| tree | 968dd89e3617e969ff201d23dc506930f0885c06 | |
| parent | 4262bf19f9234351f7879401ae325a8e144a0d40 (diff) | |
| download | cs2pov-feat/gui.tar.gz cs2pov-feat/gui.zip | |
First pass at GUIfeat/gui
Diffstat (limited to '')
| -rw-r--r-- | cs2pov/cli.py | 11 | ||||
| -rw-r--r-- | cs2pov/gui/__init__.py | 24 | ||||
| -rw-r--r-- | cs2pov/gui/app.py | 77 | ||||
| -rw-r--r-- | cs2pov/gui/pages/__init__.py | 7 | ||||
| -rw-r--r-- | cs2pov/gui/pages/info_page.py | 77 | ||||
| -rw-r--r-- | cs2pov/gui/pages/pov_page.py | 243 | ||||
| -rw-r--r-- | cs2pov/gui/pages/trim_page.py | 126 | ||||
| -rw-r--r-- | cs2pov/gui/widgets/__init__.py | 8 | ||||
| -rw-r--r-- | cs2pov/gui/widgets/demo_header.py | 44 | ||||
| -rw-r--r-- | cs2pov/gui/widgets/file_picker.py | 63 | ||||
| -rw-r--r-- | cs2pov/gui/widgets/log_console.py | 20 | ||||
| -rw-r--r-- | cs2pov/gui/widgets/player_table.py | 81 | ||||
| -rw-r--r-- | cs2pov/gui/workers.py | 236 | ||||
| -rw-r--r-- | cs2pov/loading.py | 15 | ||||
| -rw-r--r-- | setup.py | 3 |
15 files changed, 1034 insertions, 1 deletions
diff --git a/cs2pov/cli.py b/cs2pov/cli.py index c3dbf38..d80b73a 100644 --- a/cs2pov/cli.py +++ b/cs2pov/cli.py @@ -878,6 +878,13 @@ Examples: description="Generate a template cs2pov.json config with defaults and example jobs.", ) + # GUI command + subparsers.add_parser( + "gui", + help="Launch the graphical interface (requires PySide6)", + description="Open the cs2pov GUI. Install PySide6 with: pip install cs2pov[gui]", + ) + return parser @@ -1619,6 +1626,10 @@ def main() -> int: return cmd_trim(args) elif args.command == "comms": return cmd_comms(args) + elif args.command == "gui": + from .gui import launch_gui + launch_gui() + return 0 else: parser.print_help() return 1 diff --git a/cs2pov/gui/__init__.py b/cs2pov/gui/__init__.py new file mode 100644 index 0000000..0fdb358 --- /dev/null +++ b/cs2pov/gui/__init__.py @@ -0,0 +1,24 @@ +"""CS2 POV Recorder GUI - PySide6 frontend.""" + + +def launch_gui(): + """Launch the GUI application. + + Raises ImportError with install instructions if PySide6 is not available. + """ + try: + from PySide6 import QtWidgets # noqa: F401 + except ImportError: + raise SystemExit( + "PySide6 is required for the GUI.\n" + "Install it with: pip install cs2pov[gui]\n" + " or: pip install PySide6>=6.5" + ) + + from .app import MainWindow + + app = QtWidgets.QApplication([]) + app.setApplicationName("cs2pov") + window = MainWindow() + window.show() + raise SystemExit(app.exec()) diff --git a/cs2pov/gui/app.py b/cs2pov/gui/app.py new file mode 100644 index 0000000..1da91c4 --- /dev/null +++ b/cs2pov/gui/app.py @@ -0,0 +1,77 @@ +"""Main application window.""" + +import sys + +from PySide6.QtGui import QColor, QPalette +from PySide6.QtWidgets import QMainWindow, QTabWidget, QStatusBar + +from .pages import POVPage, InfoPage, TrimPage + + +def _apply_dark_theme(app): + """Apply a CS2-themed dark palette.""" + palette = QPalette() + + dark = QColor(30, 30, 36) + mid_dark = QColor(42, 42, 50) + mid = QColor(58, 58, 68) + text = QColor(220, 220, 220) + dim_text = QColor(140, 140, 150) + accent = QColor(78, 154, 255) # CS2 blue + highlight_text = QColor(255, 255, 255) + + palette.setColor(QPalette.ColorRole.Window, dark) + palette.setColor(QPalette.ColorRole.WindowText, text) + palette.setColor(QPalette.ColorRole.Base, mid_dark) + palette.setColor(QPalette.ColorRole.AlternateBase, mid) + palette.setColor(QPalette.ColorRole.ToolTipBase, mid) + palette.setColor(QPalette.ColorRole.ToolTipText, text) + palette.setColor(QPalette.ColorRole.Text, text) + palette.setColor(QPalette.ColorRole.Button, mid_dark) + palette.setColor(QPalette.ColorRole.ButtonText, text) + palette.setColor(QPalette.ColorRole.BrightText, highlight_text) + palette.setColor(QPalette.ColorRole.Link, accent) + palette.setColor(QPalette.ColorRole.Highlight, accent) + palette.setColor(QPalette.ColorRole.HighlightedText, highlight_text) + palette.setColor(QPalette.ColorRole.PlaceholderText, dim_text) + + # Disabled state + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, dim_text) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, dim_text) + palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, dim_text) + + app.setPalette(palette) + + +class MainWindow(QMainWindow): + """Main application window with tabbed interface.""" + + def __init__(self): + super().__init__() + self.setWindowTitle("cs2pov") + self.setMinimumSize(800, 700) + self.resize(900, 750) + + from PySide6.QtWidgets import QApplication + _apply_dark_theme(QApplication.instance()) + + tabs = QTabWidget() + self._pov_page = POVPage() + self._info_page = InfoPage() + self._trim_page = TrimPage() + + tabs.addTab(self._pov_page, "POV Recording") + tabs.addTab(self._info_page, "Demo Info") + tabs.addTab(self._trim_page, "Trim") + + self.setCentralWidget(tabs) + + self._status_bar = QStatusBar() + self.setStatusBar(self._status_bar) + self._status_bar.showMessage("Ready") + + # Platform check: disable recording on non-Linux + if sys.platform != "linux": + self._pov_page.set_recording_enabled(False) + self._status_bar.showMessage("Recording is only available on Linux") + diff --git a/cs2pov/gui/pages/__init__.py b/cs2pov/gui/pages/__init__.py new file mode 100644 index 0000000..a9f0c9a --- /dev/null +++ b/cs2pov/gui/pages/__init__.py @@ -0,0 +1,7 @@ +"""GUI pages (tabs).""" + +from .pov_page import POVPage +from .info_page import InfoPage +from .trim_page import TrimPage + +__all__ = ["POVPage", "InfoPage", "TrimPage"] diff --git a/cs2pov/gui/pages/info_page.py b/cs2pov/gui/pages/info_page.py new file mode 100644 index 0000000..6b4ed17 --- /dev/null +++ b/cs2pov/gui/pages/info_page.py @@ -0,0 +1,77 @@ +"""Demo Info page - parse and display demo metadata.""" + +from pathlib import Path + +from PySide6.QtWidgets import QWidget, QVBoxLayout, QPushButton + +from ..widgets import FilePicker, PlayerTable, DemoHeader, LogConsole +from ..workers import ParseWorker + + +class InfoPage(QWidget): + """Tab for viewing demo information.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._worker = None + + layout = QVBoxLayout(self) + + # File picker + self._demo_picker = FilePicker("Demo File", "Demo Files (*.dem)") + layout.addWidget(self._demo_picker) + + # Parse button + self._parse_btn = QPushButton("Parse Demo") + self._parse_btn.clicked.connect(self._on_parse) + layout.addWidget(self._parse_btn) + + # Demo header + self._demo_header = DemoHeader() + layout.addWidget(self._demo_header) + + # Player table (non-selectable, shows all players) + self._player_table = PlayerTable(selectable=False) + layout.addWidget(self._player_table, 1) + + # Log console + self._log = LogConsole() + layout.addWidget(self._log) + + def _on_parse(self): + path = self._demo_picker.path() + if not path: + self._log.append_line("No demo file selected.") + return + + demo_path = Path(path).resolve() + if not demo_path.exists(): + self._log.append_line(f"File not found: {demo_path}") + return + + self._set_busy(True) + self._log.clear() + self._demo_header.clear() + self._player_table.clear() + + self._worker = ParseWorker(demo_path) + self._worker.message.connect(self._log.append_line) + self._worker.finished.connect(self._on_parse_done) + self._worker.error.connect(self._on_error) + self._worker.start() + + def _on_parse_done(self, result): + demo_info, _ = result + self._demo_header.set_info(demo_info) + self._player_table.set_players(demo_info.players) + self._log.append_line(f"Found {len(demo_info.players)} players.") + self._set_busy(False) + + def _on_error(self, msg): + self._log.append_line(f"Error: {msg}") + self._set_busy(False) + + def _set_busy(self, busy): + self._parse_btn.setEnabled(not busy) + self._parse_btn.setText("Parsing..." if busy else "Parse Demo") + self._demo_picker.set_enabled(not busy) diff --git a/cs2pov/gui/pages/pov_page.py b/cs2pov/gui/pages/pov_page.py new file mode 100644 index 0000000..fee2fdb --- /dev/null +++ b/cs2pov/gui/pages/pov_page.py @@ -0,0 +1,243 @@ +"""POV Recording page - full recording workflow.""" + +import sys +from pathlib import Path + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QComboBox, QCheckBox, QLabel, QGroupBox, QSpinBox, +) + +from ..widgets import FilePicker, PlayerTable, DemoHeader, LogConsole +from ..workers import ParseWorker, RecordWorker + + +RESOLUTIONS = [ + ("1920x1080", (1920, 1080)), + ("2560x1440", (2560, 1440)), + ("3840x2160", (3840, 2160)), + ("1280x720", (1280, 720)), +] + + +class POVPage(QWidget): + """Tab for the full POV recording workflow.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._worker = None + self._demo_info = None + self._recording_disabled = False + + layout = QVBoxLayout(self) + + # --- File pickers --- + self._demo_picker = FilePicker("Demo File", "Demo Files (*.dem)") + layout.addWidget(self._demo_picker) + + self._output_picker = FilePicker("Output", "Video Files (*.mp4)", save_mode=True) + self._output_picker.path_changed.connect(lambda _: self._update_record_btn()) + layout.addWidget(self._output_picker) + + # --- Parse button --- + self._parse_btn = QPushButton("Parse Demo") + self._parse_btn.clicked.connect(self._on_parse) + layout.addWidget(self._parse_btn) + + # --- Demo header --- + self._demo_header = DemoHeader() + layout.addWidget(self._demo_header) + + # --- Player table --- + self._player_table = PlayerTable(selectable=True) + self._player_table.player_selected.connect(self._on_player_selected) + layout.addWidget(self._player_table, 1) + + # --- Settings --- + settings_box = QGroupBox("Recording Settings") + settings_layout = QVBoxLayout(settings_box) + + # Row 1: resolution, framerate, display + row1 = QHBoxLayout() + row1.addWidget(QLabel("Resolution:")) + self._res_combo = QComboBox() + for label, _ in RESOLUTIONS: + self._res_combo.addItem(label) + row1.addWidget(self._res_combo) + + row1.addWidget(QLabel("Framerate:")) + self._fps_spin = QSpinBox() + self._fps_spin.setRange(24, 240) + self._fps_spin.setValue(60) + row1.addWidget(self._fps_spin) + + row1.addWidget(QLabel("Display:")) + self._display_spin = QSpinBox() + self._display_spin.setRange(0, 9) + self._display_spin.setValue(0) + row1.addWidget(self._display_spin) + + row1.addStretch() + settings_layout.addLayout(row1) + + # Row 2: checkboxes + row2 = QHBoxLayout() + self._hud_cb = QCheckBox("Hide HUD") + self._hud_cb.setChecked(True) + row2.addWidget(self._hud_cb) + + self._tick_nav_cb = QCheckBox("Tick Navigation") + self._tick_nav_cb.setToolTip("Skip death periods during recording (real-time)") + row2.addWidget(self._tick_nav_cb) + + self._trim_cb = QCheckBox("Trim Deaths") + self._trim_cb.setChecked(True) + self._trim_cb.setToolTip("Post-process to remove death periods from video") + row2.addWidget(self._trim_cb) + + self._audio_cb = QCheckBox("Record Audio") + self._audio_cb.setChecked(True) + row2.addWidget(self._audio_cb) + + row2.addStretch() + settings_layout.addLayout(row2) + + layout.addWidget(settings_box) + + # --- Record button --- + self._record_btn = QPushButton("Record POV") + self._record_btn.setEnabled(False) + self._record_btn.setMinimumHeight(40) + self._record_btn.setStyleSheet( + "QPushButton:enabled { background-color: #4e9aff; color: white; font-weight: bold; font-size: 14px; }" + ) + self._record_btn.clicked.connect(self._on_record) + layout.addWidget(self._record_btn) + + # --- Log console --- + self._log = LogConsole() + layout.addWidget(self._log) + + self._selected_player_row = -1 + + def set_recording_enabled(self, enabled: bool): + """Enable/disable recording capability (platform guard).""" + self._recording_disabled = not enabled + if not enabled: + self._record_btn.setEnabled(False) + self._record_btn.setToolTip("Recording is only available on Linux") + + def _on_parse(self): + path = self._demo_picker.path() + if not path: + self._log.append_line("No demo file selected.") + return + + demo_path = Path(path).resolve() + if not demo_path.exists(): + self._log.append_line(f"File not found: {demo_path}") + return + + self._set_busy(True, "parse") + self._log.clear() + self._demo_header.clear() + self._player_table.clear() + self._demo_info = None + self._selected_player_row = -1 + self._update_record_btn() + + self._worker = ParseWorker(demo_path) + self._worker.message.connect(self._log.append_line) + self._worker.finished.connect(self._on_parse_done) + self._worker.error.connect(self._on_error) + self._worker.start() + + def _on_parse_done(self, result): + demo_info, _ = result + self._demo_info = demo_info + self._demo_header.set_info(demo_info) + self._player_table.set_players(demo_info.players) + self._log.append_line(f"Select a player to record ({len(demo_info.players)} found).") + self._set_busy(False, "parse") + + def _on_player_selected(self, row): + self._selected_player_row = row + if self._demo_info and 0 <= row < len(self._demo_info.players): + p = self._demo_info.players[row] + self._log.append_line(f"Selected: {p.name} ({p.steamid})") + self._update_record_btn() + + def _update_record_btn(self): + if self._recording_disabled: + return + can_record = ( + self._demo_info is not None + and self._selected_player_row >= 0 + and bool(self._output_picker.path()) + and self._worker is None + ) + self._record_btn.setEnabled(can_record) + + def _on_record(self): + if not self._demo_info or self._selected_player_row < 0: + return + + player = self._demo_info.players[self._selected_player_row] + demo_path = Path(self._demo_picker.path()).resolve() + output_path = Path(self._output_picker.path()).resolve() + + res_idx = self._res_combo.currentIndex() + resolution = RESOLUTIONS[res_idx][1] + + self._set_busy(True, "record") + self._log.clear() + self._log.append_line(f"Starting recording: {player.name} on {self._demo_info.map_name}") + + self._worker = RecordWorker( + demo_path=demo_path, + player_identifier=str(player.steamid), + output_path=output_path, + resolution=resolution, + framerate=self._fps_spin.value(), + hide_hud=self._hud_cb.isChecked(), + display_num=self._display_spin.value(), + enable_audio=self._audio_cb.isChecked(), + tick_nav=self._tick_nav_cb.isChecked(), + do_trim=self._trim_cb.isChecked(), + ) + self._worker.message.connect(self._log.append_line) + self._worker.finished.connect(self._on_record_done) + self._worker.error.connect(self._on_error) + self._worker.start() + + def _on_record_done(self, result): + if result and result.success: + self._log.append_line(f"\nRecording complete: {result.video_path}") + size_mb = result.video_path.stat().st_size / (1024 * 1024) + self._log.append_line(f"Size: {size_mb:.1f} MB") + elif result: + self._log.append_line(f"\nRecording ended: {result.exit_reason}") + self._set_busy(False, "record") + + def _on_error(self, msg): + self._log.append_line(f"Error: {msg}") + self._set_busy(False, "parse") + + def _set_busy(self, busy, mode="parse"): + if busy: + self._parse_btn.setEnabled(False) + self._record_btn.setEnabled(False) + self._demo_picker.set_enabled(False) + self._output_picker.set_enabled(False) + if mode == "parse": + self._parse_btn.setText("Parsing...") + else: + self._record_btn.setText("Recording...") + else: + self._worker = None + self._parse_btn.setEnabled(True) + self._parse_btn.setText("Parse Demo") + self._record_btn.setText("Record POV") + self._demo_picker.set_enabled(True) + self._output_picker.set_enabled(True) + self._update_record_btn() diff --git a/cs2pov/gui/pages/trim_page.py b/cs2pov/gui/pages/trim_page.py new file mode 100644 index 0000000..853ce68 --- /dev/null +++ b/cs2pov/gui/pages/trim_page.py @@ -0,0 +1,126 @@ +"""Trim page - post-process existing recordings.""" + +from pathlib import Path + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QLabel, QLineEdit, QCheckBox, QDoubleSpinBox, +) + +from ..widgets import FilePicker, LogConsole +from ..workers import TrimWorker + + +class TrimPage(QWidget): + """Tab for trimming death periods from existing recordings.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._worker = None + + layout = QVBoxLayout(self) + + # File pickers + self._video_picker = FilePicker("Video File", "Video Files (*.mp4 *.mkv)") + layout.addWidget(self._video_picker) + + self._demo_picker = FilePicker("Demo File", "Demo Files (*.dem)") + layout.addWidget(self._demo_picker) + + # Player input + player_row = QHBoxLayout() + lbl = QLabel("Player") + lbl.setFixedWidth(100) + player_row.addWidget(lbl) + self._player_edit = QLineEdit() + self._player_edit.setPlaceholderText("Player name or SteamID") + player_row.addWidget(self._player_edit) + layout.addLayout(player_row) + + # Output path + self._output_picker = FilePicker("Output", "Video Files (*.mp4)", save_mode=True) + layout.addWidget(self._output_picker) + + # Options row + opts = QHBoxLayout() + self._tick_nav_cb = QCheckBox("Tick-nav mode") + self._tick_nav_cb.setToolTip("Video was recorded with --tick-nav (changes trim strategy)") + opts.addWidget(self._tick_nav_cb) + + opts.addWidget(QLabel("Startup time override:")) + self._startup_spin = QDoubleSpinBox() + self._startup_spin.setRange(0, 9999) + self._startup_spin.setDecimals(1) + self._startup_spin.setSpecialValueText("auto") + self._startup_spin.setValue(0) + self._startup_spin.setSuffix(" s") + opts.addWidget(self._startup_spin) + opts.addStretch() + layout.addLayout(opts) + + # Trim button + self._trim_btn = QPushButton("Trim Video") + self._trim_btn.clicked.connect(self._on_trim) + layout.addWidget(self._trim_btn) + + # Log console + self._log = LogConsole() + layout.addWidget(self._log, 1) + + def _on_trim(self): + video = self._video_picker.path() + demo = self._demo_picker.path() + player = self._player_edit.text().strip() + + if not video or not demo or not player: + self._log.append_line("Please fill in video file, demo file, and player.") + return + + video_path = Path(video).resolve() + demo_path = Path(demo).resolve() + + if not video_path.exists(): + self._log.append_line(f"Video not found: {video_path}") + return + if not demo_path.exists(): + self._log.append_line(f"Demo not found: {demo_path}") + return + + output = self._output_picker.path() + output_path = Path(output).resolve() if output else None + + startup_time = self._startup_spin.value() if self._startup_spin.value() > 0 else None + + self._set_busy(True) + self._log.clear() + + self._worker = TrimWorker( + video_path=video_path, + demo_path=demo_path, + player_identifier=player, + output_path=output_path, + tick_nav=self._tick_nav_cb.isChecked(), + startup_time=startup_time, + ) + self._worker.message.connect(self._log.append_line) + self._worker.finished.connect(self._on_done) + self._worker.error.connect(self._on_error) + self._worker.start() + + def _on_done(self, result): + if result: + path, segments = result + self._log.append_line(f"Trimmed video saved: {path}") + self._set_busy(False) + + def _on_error(self, msg): + self._log.append_line(f"Error: {msg}") + self._set_busy(False) + + def _set_busy(self, busy): + self._trim_btn.setEnabled(not busy) + self._trim_btn.setText("Trimming..." if busy else "Trim Video") + self._video_picker.set_enabled(not busy) + self._demo_picker.set_enabled(not busy) + self._output_picker.set_enabled(not busy) + self._player_edit.setEnabled(not busy) diff --git a/cs2pov/gui/widgets/__init__.py b/cs2pov/gui/widgets/__init__.py new file mode 100644 index 0000000..3cb0237 --- /dev/null +++ b/cs2pov/gui/widgets/__init__.py @@ -0,0 +1,8 @@ +"""Reusable GUI widgets.""" + +from .file_picker import FilePicker +from .player_table import PlayerTable +from .log_console import LogConsole +from .demo_header import DemoHeader + +__all__ = ["FilePicker", "PlayerTable", "LogConsole", "DemoHeader"] diff --git a/cs2pov/gui/widgets/demo_header.py b/cs2pov/gui/widgets/demo_header.py new file mode 100644 index 0000000..7212755 --- /dev/null +++ b/cs2pov/gui/widgets/demo_header.py @@ -0,0 +1,44 @@ +"""Demo metadata display widget.""" + +from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel + + +class DemoHeader(QWidget): + """Displays demo metadata: map, tickrate, duration, ticks.""" + + def __init__(self, parent=None): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self._map_label = QLabel() + self._tickrate_label = QLabel() + self._ticks_label = QLabel() + self._duration_label = QLabel() + + for label in (self._map_label, self._tickrate_label, self._ticks_label, self._duration_label): + label.setStyleSheet("font-weight: bold; padding: 4px 8px;") + layout.addWidget(label) + + layout.addStretch() + self.clear() + + def set_info(self, demo_info): + """Update from a DemoInfo dataclass. + + Args: + demo_info: parser.DemoInfo instance + """ + self._map_label.setText(f"Map: {demo_info.map_name}") + self._tickrate_label.setText(f"Tickrate: {demo_info.tick_rate:.0f}") + self._ticks_label.setText(f"Ticks: {demo_info.total_ticks}") + if demo_info.total_ticks and demo_info.tick_rate: + duration = demo_info.total_ticks / demo_info.tick_rate + mins, secs = divmod(int(duration), 60) + self._duration_label.setText(f"Duration: {mins}:{secs:02d}") + else: + self._duration_label.setText("Duration: N/A") + + def clear(self): + for label in (self._map_label, self._tickrate_label, self._ticks_label, self._duration_label): + label.setText("") diff --git a/cs2pov/gui/widgets/file_picker.py b/cs2pov/gui/widgets/file_picker.py new file mode 100644 index 0000000..49a5856 --- /dev/null +++ b/cs2pov/gui/widgets/file_picker.py @@ -0,0 +1,63 @@ +"""File picker widget - label + path display + Browse button.""" + +from pathlib import Path + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog + + +class FilePicker(QWidget): + """Row widget: label | path text field | Browse button.""" + + path_changed = Signal(str) + + def __init__( + self, + label: str, + filter_str: str = "", + save_mode: bool = False, + directory_mode: bool = False, + parent=None, + ): + super().__init__(parent) + self._filter_str = filter_str + self._save_mode = save_mode + self._directory_mode = directory_mode + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self._label = QLabel(label) + self._label.setFixedWidth(100) + layout.addWidget(self._label) + + self._line_edit = QLineEdit() + self._line_edit.setPlaceholderText("No file selected") + self._line_edit.textChanged.connect(self.path_changed.emit) + layout.addWidget(self._line_edit, 1) + + self._browse_btn = QPushButton("Browse") + self._browse_btn.setFixedWidth(80) + self._browse_btn.clicked.connect(self._browse) + layout.addWidget(self._browse_btn) + + def _browse(self): + if self._directory_mode: + path = QFileDialog.getExistingDirectory(self, "Select Directory") + elif self._save_mode: + path, _ = QFileDialog.getSaveFileName(self, "Save File", "", self._filter_str) + else: + path, _ = QFileDialog.getOpenFileName(self, "Open File", "", self._filter_str) + + if path: + self._line_edit.setText(path) + + def path(self) -> str: + return self._line_edit.text().strip() + + def set_path(self, path: str): + self._line_edit.setText(path) + + def set_enabled(self, enabled: bool): + self._line_edit.setEnabled(enabled) + self._browse_btn.setEnabled(enabled) diff --git a/cs2pov/gui/widgets/log_console.py b/cs2pov/gui/widgets/log_console.py new file mode 100644 index 0000000..a158902 --- /dev/null +++ b/cs2pov/gui/widgets/log_console.py @@ -0,0 +1,20 @@ +"""Read-only log console widget.""" + +from PySide6.QtWidgets import QTextEdit +from PySide6.QtGui import QFont, QTextCursor + + +class LogConsole(QTextEdit): + """Read-only text area that shows log output, auto-scrolling to bottom.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setReadOnly(True) + self.setFont(QFont("Monospace", 9)) + self.setMinimumHeight(120) + + def append_line(self, text: str): + """Append a line and scroll to bottom.""" + self.moveCursor(QTextCursor.MoveOperation.End) + self.insertPlainText(text + "\n") + self.moveCursor(QTextCursor.MoveOperation.End) diff --git a/cs2pov/gui/widgets/player_table.py b/cs2pov/gui/widgets/player_table.py new file mode 100644 index 0000000..18786df --- /dev/null +++ b/cs2pov/gui/widgets/player_table.py @@ -0,0 +1,81 @@ +"""Player table widget with radio button selection.""" + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QTableWidget, QTableWidgetItem, + QRadioButton, QHeaderView, QButtonGroup, +) + + +class PlayerTable(QWidget): + """Table showing players from a demo with optional radio selection.""" + + player_selected = Signal(int) # Emits row index + + def __init__(self, selectable: bool = True, parent=None): + super().__init__(parent) + self._selectable = selectable + self._button_group = QButtonGroup(self) if selectable else None + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + columns = ["Name", "SteamID", "Team", "Kills", "Assists"] + if selectable: + columns = [""] + columns + + self._table = QTableWidget(0, len(columns)) + self._table.setHorizontalHeaderLabels(columns) + self._table.verticalHeader().setVisible(False) + self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self._table.setSelectionMode(QTableWidget.SelectionMode.NoSelection) + + header = self._table.horizontalHeader() + if selectable: + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + self._table.setColumnWidth(0, 30) + for i in range(1, len(columns)): + header.setSectionResizeMode(i, QHeaderView.ResizeMode.Stretch) + else: + for i in range(len(columns)): + header.setSectionResizeMode(i, QHeaderView.ResizeMode.Stretch) + + layout.addWidget(self._table) + + def set_players(self, players): + """Populate table from a list of PlayerInfo objects. + + Args: + players: list of parser.PlayerInfo dataclass instances + """ + self._table.setRowCount(0) + if self._button_group: + # Remove old buttons from group + for btn in self._button_group.buttons(): + self._button_group.removeButton(btn) + + for row, p in enumerate(players): + self._table.insertRow(row) + col_offset = 0 + + if self._selectable: + radio = QRadioButton() + radio.clicked.connect(lambda checked, r=row: self.player_selected.emit(r)) + self._button_group.addButton(radio, row) + self._table.setCellWidget(row, 0, radio) + col_offset = 1 + + self._table.setItem(row, col_offset, QTableWidgetItem(p.name)) + self._table.setItem(row, col_offset + 1, QTableWidgetItem(str(p.steamid))) + self._table.setItem(row, col_offset + 2, QTableWidgetItem(p.team or "")) + self._table.setItem(row, col_offset + 3, QTableWidgetItem(str(p.kills))) + self._table.setItem(row, col_offset + 4, QTableWidgetItem(str(p.assists))) + + def selected_row(self) -> int: + """Return selected row index, or -1 if none selected.""" + if not self._button_group: + return -1 + return self._button_group.checkedId() + + def clear(self): + self._table.setRowCount(0) diff --git a/cs2pov/gui/workers.py b/cs2pov/gui/workers.py new file mode 100644 index 0000000..854fad3 --- /dev/null +++ b/cs2pov/gui/workers.py @@ -0,0 +1,236 @@ +"""Background workers for long-running operations.""" + +import builtins +import sys +import traceback +from contextlib import contextmanager +from pathlib import Path + +from PySide6.QtCore import QThread, Signal, QObject + + +@contextmanager +def capture_prints(callback): + """Temporarily redirect builtins.print to a callback. + + Only redirects prints to stdout/None (default). Prints to stderr or + other file objects pass through to the original print. + """ + original = builtins.print + + def redirect(*args, **kwargs): + if kwargs.get("file") not in (None, sys.stdout): + original(*args, **kwargs) + return + callback(" ".join(str(a) for a in args)) + + builtins.print = redirect + try: + yield + finally: + builtins.print = original + + +class ParseWorker(QThread): + """Parse a demo file in the background.""" + + message = Signal(str) + finished = Signal(object) # Emits (DemoInfo, DemoTimeline | None) + error = Signal(str) + + def __init__(self, demo_path: Path, player_steamid: int = None, player_name: str = "", parent=None): + super().__init__(parent) + self.demo_path = demo_path + self.player_steamid = player_steamid + self.player_name = player_name + + def run(self): + try: + from ..parser import parse_demo + from ..preprocessor import preprocess_demo + from ..loading import _set_gui_mode + + _set_gui_mode(True) + + with capture_prints(self.message.emit): + self.message.emit(f"Parsing demo: {self.demo_path.name}") + demo_info = parse_demo(self.demo_path) + self.message.emit( + f" Map: {demo_info.map_name}, {len(demo_info.players)} players, " + f"Ticks: {demo_info.total_ticks}, Rate: {demo_info.tick_rate}" + ) + + timeline = None + if self.player_steamid is not None: + self.message.emit(f"Preprocessing timeline for {self.player_name or self.player_steamid}...") + timeline = preprocess_demo(self.demo_path, self.player_steamid, self.player_name) + self.message.emit(f" {len(timeline.alive_segments)} alive segments, {len(timeline.deaths)} deaths") + + self.finished.emit((demo_info, timeline)) + + except Exception as e: + self.error.emit(f"{type(e).__name__}: {e}") + finally: + from ..loading import _set_gui_mode + _set_gui_mode(False) + + +class RecordWorker(QThread): + """Run the full recording pipeline in the background.""" + + message = Signal(str) + finished = Signal(object) # Emits RecordingResult or None + error = Signal(str) + + def __init__( + self, + demo_path: Path, + player_identifier: str, + output_path: Path, + resolution: tuple = (1920, 1080), + framerate: int = 60, + hide_hud: bool = True, + display_num: int = 0, + enable_audio: bool = True, + tick_nav: bool = False, + do_trim: bool = True, + cs2_path_override: Path = None, + verbose: bool = False, + parent=None, + ): + super().__init__(parent) + self.demo_path = demo_path + self.player_identifier = player_identifier + self.output_path = output_path + self.resolution = resolution + self.framerate = framerate + self.hide_hud = hide_hud + self.display_num = display_num + self.enable_audio = enable_audio + self.tick_nav = tick_nav + self.do_trim = do_trim + self.cs2_path_override = cs2_path_override + self.verbose = verbose + + def run(self): + try: + from ..cli import record_demo, postprocess_video + from ..loading import _set_gui_mode + + _set_gui_mode(True) + + with capture_prints(self.message.emit): + result = record_demo( + demo_path=self.demo_path, + player_identifier=self.player_identifier, + output_path=self.output_path, + resolution=self.resolution, + framerate=self.framerate, + hide_hud=self.hide_hud, + display_num=self.display_num, + verbose=self.verbose, + cs2_path_override=self.cs2_path_override, + enable_audio=self.enable_audio, + tick_nav=self.tick_nav, + ) + + if result.success and self.do_trim: + self.message.emit("\nPost-processing: trimming death periods...") + postprocess_video( + video_path=result.video_path, + console_log_path=result.console_log_path, + verbose=self.verbose, + timeline=result.timeline, + transitions=result.transitions, + ) + + self.finished.emit(result) + + except Exception as e: + tb = traceback.format_exc() + self.error.emit(f"{type(e).__name__}: {e}\n{tb}") + finally: + from ..loading import _set_gui_mode + _set_gui_mode(False) + + +class TrimWorker(QThread): + """Run post-processing trim in the background.""" + + message = Signal(str) + finished = Signal(object) # Emits (output_path, keep_segments) or None + error = Signal(str) + + def __init__( + self, + video_path: Path, + demo_path: Path, + player_identifier: str, + output_path: Path = None, + tick_nav: bool = False, + startup_time: float = None, + verbose: bool = False, + parent=None, + ): + super().__init__(parent) + self.video_path = video_path + self.demo_path = demo_path + self.player_identifier = player_identifier + self.output_path = output_path + self.tick_nav = tick_nav + self.startup_time = startup_time + self.verbose = verbose + + def run(self): + try: + from ..parser import parse_demo, find_player + from ..preprocessor import preprocess_demo + from ..cli import postprocess_video + from ..trim import load_transitions + from ..loading import _set_gui_mode + + _set_gui_mode(True) + + with capture_prints(self.message.emit): + self.message.emit(f"Parsing demo: {self.demo_path.name}") + demo_info = parse_demo(self.demo_path) + player = find_player(demo_info, self.player_identifier) + self.message.emit(f"Player: {player.name} ({player.steamid})") + + self.message.emit("Preprocessing timeline...") + timeline = preprocess_demo(self.demo_path, player.steamid, player.name) + + transitions = None + if self.tick_nav: + transitions = load_transitions(self.video_path) + + # Determine output path + video_path = self.video_path + if self.output_path: + # Copy input to output location, trim will work on it + import shutil + shutil.copy2(self.video_path, self.output_path) + video_path = self.output_path + + # Build a fake console log path (postprocess_video needs it for demo end detection) + console_log_path = self.video_path.parent / f"console_{self.demo_path.stem}.log" + + self.message.emit("Trimming...") + result_path, keep_segments = postprocess_video( + video_path=video_path, + console_log_path=console_log_path, + verbose=self.verbose, + timeline=timeline, + startup_time_override=self.startup_time, + transitions=transitions, + ) + + self.message.emit(f"Done: {result_path}") + self.finished.emit((result_path, keep_segments)) + + except Exception as e: + tb = traceback.format_exc() + self.error.emit(f"{type(e).__name__}: {e}\n{tb}") + finally: + from ..loading import _set_gui_mode + _set_gui_mode(False) diff --git a/cs2pov/loading.py b/cs2pov/loading.py index 5c57bb9..9f18439 100644 --- a/cs2pov/loading.py +++ b/cs2pov/loading.py @@ -5,6 +5,15 @@ import sys import threading import time +# GUI mode flag: when True, LoadingAnimation.start() is a no-op +_gui_mode = False + + +def _set_gui_mode(value: bool): + """Enable/disable GUI mode. When enabled, LoadingAnimation does nothing.""" + global _gui_mode + _gui_mode = value + # ============================================================================= # Loading Messages (add more here!) # ============================================================================= @@ -118,7 +127,9 @@ class LoadingAnimation: self._draw() def start(self): - """Start the animation.""" + """Start the animation. No-op in GUI mode.""" + if _gui_mode: + return self._stop_event.clear() self._current_dots = self.min_dots self._stderr = sys.stderr @@ -154,6 +165,8 @@ class LoadingAnimation: def stop(self): """Stop the animation and clear the line.""" + if _gui_mode: + return with self._lock: self._active = False @@ -19,6 +19,9 @@ setup( python_requires=">=3.10", packages=find_packages(), install_requires=requirements, + extras_require={ + "gui": ["PySide6>=6.5"], + }, entry_points={ "console_scripts": [ "cs2pov=cs2pov.cli:main", |
