From 81bf6e312d7841cba95a8a67e9d5fb8855fe92bb Mon Sep 17 00:00:00 2001 From: Eric Xiao Date: Mon, 8 Dec 2025 14:06:25 -0800 Subject: [PATCH 1/2] Add simulated test toolbar to thunderscope --- src/software/thunderscope/gl/BUILD | 1 + src/software/thunderscope/gl/gl_widget.py | 7 +++ src/software/thunderscope/gl/widgets/BUILD | 11 ++++ .../gl/widgets/gl_gamecontroller_toolbar.py | 52 ++++--------------- .../gl/widgets/gl_simulated_test_toolbar.py | 33 ++++++++++++ .../thunderscope/gl/widgets/gl_toolbar.py | 37 ++++++++++++- 6 files changed, 97 insertions(+), 44 deletions(-) create mode 100644 src/software/thunderscope/gl/widgets/gl_simulated_test_toolbar.py diff --git a/src/software/thunderscope/gl/BUILD b/src/software/thunderscope/gl/BUILD index 8bfb8a6d48..6c7ae2422f 100644 --- a/src/software/thunderscope/gl/BUILD +++ b/src/software/thunderscope/gl/BUILD @@ -13,6 +13,7 @@ py_library( "//software/thunderscope/gl/layers:gl_measure_layer", "//software/thunderscope/gl/widgets:gl_field_toolbar", "//software/thunderscope/gl/widgets:gl_gamecontroller_toolbar", + "//software/thunderscope/gl/widgets:gl_simulated_test_toolbar", "//software/thunderscope/replay:replay_controls", requirement("pyqtgraph"), ], diff --git a/src/software/thunderscope/gl/gl_widget.py b/src/software/thunderscope/gl/gl_widget.py index 28f64a299e..5e37476b48 100644 --- a/src/software/thunderscope/gl/gl_widget.py +++ b/src/software/thunderscope/gl/gl_widget.py @@ -21,6 +21,9 @@ from software.thunderscope.gl.widgets.gl_gamecontroller_toolbar import ( GLGamecontrollerToolbar, ) +from software.thunderscope.gl.widgets.gl_simulated_test_toolbar import ( + GLSimulatedTestToolbar, +) from software.thunderscope.thread_safe_buffer import ThreadSafeBuffer from proto.world_pb2 import SimulationState from proto.replay_bookmark_pb2 import ReplayBookmark @@ -113,7 +116,10 @@ def __init__( friendly_color_yellow=friendly_color_yellow, ) + self.simulated_test_toolbar = GLSimulatedTestToolbar(parent=self.gl_view_widget) + self.__add_toolbar_toggle(self.gamecontroller_toolbar, "Gamecontroller") + self.__add_toolbar_toggle(self.simulated_test_toolbar, "Tests") # Setup replay controls if player is provided and the log has some size self.player = player @@ -269,6 +275,7 @@ def refresh(self) -> None: if self.simulation_control_toolbar: self.simulation_control_toolbar.refresh() self.gamecontroller_toolbar.refresh() + self.simulated_test_toolbar.refresh() simulation_state = self.simulation_state_buffer.get(block=False) # Don't refresh the layers if the simulation is paused diff --git a/src/software/thunderscope/gl/widgets/BUILD b/src/software/thunderscope/gl/widgets/BUILD index 7526278ced..b3bb9192ad 100644 --- a/src/software/thunderscope/gl/widgets/BUILD +++ b/src/software/thunderscope/gl/widgets/BUILD @@ -42,3 +42,14 @@ py_library( requirement("qtawesome"), ], ) + +py_library( + name = "gl_simulated_test_toolbar", + srcs = ["gl_simulated_test_toolbar.py"], + deps = [ + ":gl_toolbar", + "//software/thunderscope/common:common_widgets", + requirement("pyqtgraph"), + requirement("qtawesome"), + ], +) diff --git a/src/software/thunderscope/gl/widgets/gl_gamecontroller_toolbar.py b/src/software/thunderscope/gl/widgets/gl_gamecontroller_toolbar.py index 042b6622e8..f263f5e6e3 100644 --- a/src/software/thunderscope/gl/widgets/gl_gamecontroller_toolbar.py +++ b/src/software/thunderscope/gl/widgets/gl_gamecontroller_toolbar.py @@ -2,7 +2,7 @@ from pyqtgraph.Qt import QtGui from proto.import_all_protos import * from proto.ssl_gc_common_pb2 import Team as SslTeam -from typing import Callable, override +from typing import override import webbrowser from software.thunderscope.gl.widgets.gl_toolbar import GLToolbar from software.thunderscope.proto_unix_io import ProtoUnixIO @@ -48,28 +48,28 @@ def __init__( self.friendly_color_yellow = friendly_color_yellow # Setup Stop button for sending the STOP gamecontroller command - self.stop_button = self.__setup_icon_button( + self.stop_button = self.setup_icon_button( qta.icon("fa6s.pause"), "Stops gameplay, robots form circle around ball", self.__send_stop_command, ) # Setup Force Start button for sending the FORCE_START gamecontroller command - self.force_start_button = self.__setup_icon_button( + self.force_start_button = self.setup_icon_button( qta.icon("ph.arrow-u-up-right-fill"), "Force Start, restarts the game", self.__send_force_start_command, ) # Setup Halt button for sending the HALT gamecontroller command - self.halt_button = self.__setup_icon_button( + self.halt_button = self.setup_icon_button( qta.icon("fa5s.stop"), "Halt, stops all robots immediately", self.__send_halt_command, ) # Setup Normal Start button for sending the NORMAL_START gamecontroller command - self.normal_start_button = self.__setup_icon_button( + self.normal_start_button = self.setup_icon_button( qta.icon("fa5s.play"), "Normal Start, resumes game from a set play (disabled when no play selected)", self.__send_normal_start_command, @@ -88,7 +88,7 @@ def __init__( self.plays_menu.addSeparator() self.__add_plays_menu_items(is_blue=False) - self.gc_browser_button = self.__setup_icon_button( + self.gc_browser_button = self.setup_icon_button( qta.icon("mdi6.open-in-new"), "Opens the SSL Gamecontroller in a browser window", lambda: webbrowser.open(self.GAME_CONTROLLER_URL, new=0, autoraise=True), @@ -100,14 +100,14 @@ def __init__( self.__toggle_normal_start_button() self.layout().addWidget(QLabel("Gamecontroller")) - self.__add_separator(self.layout()) + self.add_separator(self.layout()) self.layout().addWidget(self.stop_button) self.layout().addWidget(self.halt_button) self.layout().addWidget(self.force_start_button) - self.__add_separator(self.layout()) + self.add_separator(self.layout()) self.layout().addWidget(self.plays_menu_button) self.layout().addWidget(self.normal_start_button) - self.__add_separator(self.layout()) + self.add_separator(self.layout()) self.layout().addWidget(self.gc_browser_button) self.layout().addStretch() @@ -116,15 +116,6 @@ def refresh(self) -> None: """Refreshes the UI to update toolbar position""" self.move(0, self.parentWidget().geometry().bottom() - self.height()) - def __add_separator(self, layout: QBoxLayout) -> None: - """Adds a separator line with enough spacing to the given layout - - :param layout: the layout to add the separator to - """ - layout.addSpacing(10) - layout.addWidget(QLabel("|")) - layout.addSpacing(10) - def __add_plays_menu_items(self, is_blue: bool) -> None: """Initializes the plays menu with the available plays for the given team @@ -189,31 +180,6 @@ def __toggle_normal_start_button(self) -> None: ) ) - def __setup_icon_button( - self, - icon: QtGui.QPixmap, - tooltip: str, - callback: Callable[[], None], - display_text: str = None, - ) -> QPushButton: - """Sets up a button with the given name and callback - - :param icon: the icon displayed on the button - :param tooltip: the tooltip displayed when hovering over the button - :param callback: the callback for the button click - :param display_text: optional param if button needs both text and an icon - :return: the button - """ - button = QPushButton() - button.setIcon(icon) - button.setToolTip(tooltip) - button.setStyleSheet(self.get_button_style()) - button.clicked.connect(callback) - - if display_text: - button.setText(display_text) - return button - def __send_stop_command(self) -> None: """Sends a STOP command to the gamecontroller""" self.__send_gc_command(Command.Type.STOP, SslTeam.UNKNOWN) diff --git a/src/software/thunderscope/gl/widgets/gl_simulated_test_toolbar.py b/src/software/thunderscope/gl/widgets/gl_simulated_test_toolbar.py new file mode 100644 index 0000000000..fd1b3f1fe5 --- /dev/null +++ b/src/software/thunderscope/gl/widgets/gl_simulated_test_toolbar.py @@ -0,0 +1,33 @@ +from pyqtgraph.Qt import QtWidgets +from software.thunderscope.gl.widgets.gl_toolbar import GLToolbar +import qtawesome as qta +from typing import override + + +class GLSimulatedTestToolbar(GLToolbar): + """A toolbar with controls to run simulated tests within Thunderscope""" + + def __init__(self, parent: QtWidgets.QWidget): + """Initializes the toolbar and constructs its layout + + :param parent: the parent to overlay this toolbar over + """ + super(GLSimulatedTestToolbar, self).__init__(parent=parent) + + self.run_test_button = self.setup_icon_button( + qta.icon("fa5s.play"), + "Runs simluated test", + self.__run_test, + ) + + self.layout().addWidget(QtWidgets.QLabel("Simulated Tests")) + self.add_separator(self.layout()) + self.layout().addWidget(self.run_test_button) + + @override + def refresh(self) -> None: + """Refreshes the UI to update toolbar position""" + self.move(0, self.parentWidget().geometry().bottom() - self.height()) + + def __run_test(self): + print("RUN TEST") diff --git a/src/software/thunderscope/gl/widgets/gl_toolbar.py b/src/software/thunderscope/gl/widgets/gl_toolbar.py index 06963ae8da..265943ad0e 100644 --- a/src/software/thunderscope/gl/widgets/gl_toolbar.py +++ b/src/software/thunderscope/gl/widgets/gl_toolbar.py @@ -1,6 +1,7 @@ import textwrap -from pyqtgraph.Qt import QtCore +from pyqtgraph.Qt import QtCore, QtGui from pyqtgraph.Qt.QtWidgets import * +from typing import Callable class GLToolbar(QWidget): @@ -54,3 +55,37 @@ def get_button_style(self, is_enabled: bool = True) -> str: }} """ ) + + def setup_icon_button( + self, + icon: QtGui.QPixmap, + tooltip: str, + callback: Callable[[], None], + display_text: str = None, + ) -> QPushButton: + """Sets up a button with the given name and callback + + :param icon: the icon displayed on the button + :param tooltip: the tooltip displayed when hovering over the button + :param callback: the callback for the button click + :param display_text: optional param if button needs both text and an icon + :return: the button + """ + button = QPushButton() + button.setIcon(icon) + button.setToolTip(tooltip) + button.setStyleSheet(self.get_button_style()) + button.clicked.connect(callback) + + if display_text: + button.setText(display_text) + return button + + def add_separator(self, layout: QBoxLayout) -> None: + """Adds a separator line with enough spacing to the given layout + + :param layout: the layout to add the separator to + """ + layout.addSpacing(10) + layout.addWidget(QLabel("|")) + layout.addSpacing(10) From f6d8e7c717b61b647bf468bcda45cc696ec8cb1e Mon Sep 17 00:00:00 2001 From: Eric Xiao Date: Sun, 21 Dec 2025 10:54:03 -0800 Subject: [PATCH 2/2] Use select menu for toolbars instead of allowing toggle multiple --- src/software/thunderscope/gl/gl_widget.py | 33 ++++++++++++----------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/software/thunderscope/gl/gl_widget.py b/src/software/thunderscope/gl/gl_widget.py index 5e37476b48..0275d18bec 100644 --- a/src/software/thunderscope/gl/gl_widget.py +++ b/src/software/thunderscope/gl/gl_widget.py @@ -118,8 +118,10 @@ def __init__( self.simulated_test_toolbar = GLSimulatedTestToolbar(parent=self.gl_view_widget) - self.__add_toolbar_toggle(self.gamecontroller_toolbar, "Gamecontroller") - self.__add_toolbar_toggle(self.simulated_test_toolbar, "Tests") + self.__add_toolbar_select(self.gamecontroller_toolbar, "Gamecontroller") + self.__add_toolbar_select(self.simulated_test_toolbar, "Tests") + self.current_toolbar = self.gamecontroller_toolbar + self.simulated_test_toolbar.setVisible(False) # Setup replay controls if player is provided and the log has some size self.player = player @@ -329,22 +331,23 @@ def toggle_measure_mode(self) -> None: else: self.remove_layer(self.measure_layer) - def __add_toolbar_toggle(self, toolbar: QWidget, name: str) -> None: - """Adds a button to the toolbar menu to toggle the given toolbar + def __select_toolbar(self, toolbar: QWidget) -> None: + """Sets the currently selected toolbar to be only one visible - :param toolbar: the toolbar to add the toggle button for - :param name: the display name of the toolbar + :param toolbar: the toolbar to select """ - # Add a menu item for the Gamecontroller toolbar - (toolbar_checkbox, toolbar_action) = self.__setup_menu_checkbox( - name, self.toolbars_menu - ) - self.toolbars_menu.addAction(toolbar_action) + self.gamecontroller_toolbar.setVisible(False) + self.simulated_test_toolbar.setVisible(False) + toolbar.setVisible(True) + self.current_toolbar = toolbar - # Connect visibility of the toolbar to the menu item - toolbar_checkbox.stateChanged.connect( - lambda: toolbar.setVisible(toolbar_checkbox.isChecked()) - ) + def __add_toolbar_select(self, toolbar: QWidget, name: str) -> None: + """Adds a button to the toolbar menu to select the given toolbar + + :param toolbar: the toolbar to add the select button for + :param name: the display name of the toolbar + """ + self.toolbars_menu.addAction(name, lambda: self.__select_toolbar(toolbar)) def __setup_menu_checkbox( self, name: str, parent: QWidget, checked: bool = True