diff --git a/src/surface/gui/gui/pilot_app.py b/src/surface/gui/gui/pilot_app.py index 3abbb719..6af88616 100644 --- a/src/surface/gui/gui/pilot_app.py +++ b/src/surface/gui/gui/pilot_app.py @@ -19,7 +19,7 @@ def __init__(self) -> None: layout = QHBoxLayout() self.setLayout(layout) - # Look into QStackedLayout for possibly switching between + # TODO Look into QStackedLayout for possibly switching between # 1 big camera feed and 2 smaller ones video_area = SwitchableVideoWidget( [ diff --git a/src/surface/gui/gui/styles/custom_styles.py b/src/surface/gui/gui/styles/custom_styles.py index 9a0d2062..f914a1fe 100644 --- a/src/surface/gui/gui/styles/custom_styles.py +++ b/src/surface/gui/gui/styles/custom_styles.py @@ -1,21 +1,45 @@ -class Style: - """Represents a single class that can be applied to gui objects to change their appearance.""" +from PyQt6.QtWidgets import QWidget, QPushButton - PROPERTY_NAME: str +class IndicatorMixin(QWidget): -class WidgetState(Style): - """Represents the state of a widget that can be alternately active or inactive.""" - - PROPERTY_NAME = "widgetState" + _PROPERTY_NAME = "widgetState" # A component is running, enabled, or armed - ON = "on" + _ON = "on" # A component is disabled, not running, or disarmed, but could be enabled through this widget - OFF = "off" + _OFF = "off" # A component is disabled, not expected to have any effect or perform its function because of # some external factor, either another widget or something external to the gui # For example, a the arm button when the pi is not connected - INACTIVE = "inactive" + _INACTIVE = "inactive" + + # Removes any state + _NO_STATE = "" + + def set_on(self) -> None: + self.setProperty(IndicatorMixin._PROPERTY_NAME, IndicatorMixin._ON) + self._update_style() + + def set_off(self) -> None: + self.setProperty(IndicatorMixin._PROPERTY_NAME, IndicatorMixin._OFF) + self._update_style() + + def set_inactive(self) -> None: + self.setProperty(IndicatorMixin._PROPERTY_NAME, IndicatorMixin._INACTIVE) + self._update_style() + + def remove_state(self) -> None: + self.setProperty(IndicatorMixin._PROPERTY_NAME, IndicatorMixin._NO_STATE) + self._update_style() + + def _update_style(self) -> None: + style = self.style() + if style is not None: + style.polish(self) + + +class ButtonIndicator(QPushButton, IndicatorMixin): + pass diff --git a/src/surface/gui/gui/widgets/arm.py b/src/surface/gui/gui/widgets/arm.py index 010dd47d..3df646c9 100644 --- a/src/surface/gui/gui/widgets/arm.py +++ b/src/surface/gui/gui/widgets/arm.py @@ -5,7 +5,7 @@ GUIEventSubscriber, ) from gui.styles.custom_styles import ( - WidgetState, + ButtonIndicator ) from mavros_msgs.srv import CommandBool from PyQt6.QtCore import ( @@ -14,7 +14,6 @@ ) from PyQt6.QtWidgets import ( QHBoxLayout, - QPushButton, QWidget, ) @@ -30,7 +29,7 @@ class Arm(QWidget): BUTTON_HEIGHT = 60 BUTTON_STYLESHEET = "QPushButton { font-size: 20px; }" - command_response_signal: pyqtSignal = pyqtSignal(CommandBool.Response) + command_response_signal = pyqtSignal(CommandBool.Response) vehicle_state_signal = pyqtSignal(VehicleState) def __init__(self) -> None: @@ -39,8 +38,8 @@ def __init__(self) -> None: layout = QHBoxLayout() self.setLayout(layout) - self.arm_button = QPushButton() - self.disarm_button = QPushButton() + self.arm_button = ButtonIndicator() + self.disarm_button = ButtonIndicator() self.arm_button.setText("Arm") self.disarm_button.setText("Disarm") @@ -53,14 +52,8 @@ def __init__(self) -> None: self.arm_button.setStyleSheet(self.BUTTON_STYLESHEET) self.disarm_button.setStyleSheet(self.BUTTON_STYLESHEET) - self.arm_button.setProperty( - WidgetState.PROPERTY_NAME, - WidgetState.INACTIVE, - ) - self.disarm_button.setProperty( - WidgetState.PROPERTY_NAME, - WidgetState.INACTIVE, - ) + self.arm_button.set_inactive() + self.disarm_button.set_inactive() self.arm_button.clicked.connect(self.arm_clicked) self.disarm_button.clicked.connect(self.disarm_clicked) @@ -100,39 +93,11 @@ def arm_status(self, res: CommandBool.Response) -> None: def vehicle_state_callback(self, msg: VehicleState) -> None: if msg.pixhawk_connected: if msg.armed: - self.arm_button.setProperty( - WidgetState.PROPERTY_NAME, - WidgetState.ON, - ) - self.disarm_button.setProperty( - WidgetState.PROPERTY_NAME, - "", - ) + self.arm_button.set_on() + self.disarm_button.remove_state() else: - self.arm_button.setProperty( - WidgetState.PROPERTY_NAME, - "", - ) - self.disarm_button.setProperty( - WidgetState.PROPERTY_NAME, - WidgetState.OFF, - ) + self.arm_button.remove_state() + self.disarm_button.set_off() else: - self.arm_button.setProperty( - WidgetState.PROPERTY_NAME, - WidgetState.INACTIVE, - ) - self.disarm_button.setProperty( - WidgetState.PROPERTY_NAME, - WidgetState.INACTIVE, - ) - - for button in ( - self.arm_button, - self.disarm_button, - ): - style = button.style() - if style is not None: - style.polish(button) - else: - self.arm_client.get_logger().error("Gui button missing a style") + self.arm_button.set_inactive() + self.disarm_button.set_inactive() diff --git a/src/surface/gui/gui/widgets/circle.py b/src/surface/gui/gui/widgets/circle.py new file mode 100644 index 00000000..23e6d873 --- /dev/null +++ b/src/surface/gui/gui/widgets/circle.py @@ -0,0 +1,31 @@ +from typing import Optional +from gui.styles.custom_styles import IndicatorMixin +from PyQt6.QtCore import QSize, Qt +from PyQt6.QtGui import QColor +from PyQt6.QtWidgets import QWidget, QLabel + + +class Circle(QLabel): + def __init__(self, parent: Optional[QWidget] = None, + radius: int = 50, + color: Optional[QColor | Qt.GlobalColor] = None) -> None: + super().__init__(parent) + self.setFixedSize(QSize(2 * radius, 2 * radius)) + stylesheet = self.styleSheet() + self.setStyleSheet(f"{stylesheet}border-radius: {radius}px;") + + if color: + self.set_color(color) + + def set_color(self, color: QColor | Qt.GlobalColor) -> None: + if isinstance(color, Qt.GlobalColor): + color = QColor(color) + style = f"background-color: rgb({color.red()}, {color.green()}, {color.blue()});" + self.setStyleSheet(f"{self.styleSheet()}{style}") + + +class CircleIndicator(Circle, IndicatorMixin): + def __init__(self, parent: Optional[QWidget] = None, + radius: int = 50) -> None: + super().__init__(parent, radius) + self.set_inactive() diff --git a/src/surface/gui/gui/widgets/debug_tab.py b/src/surface/gui/gui/widgets/debug_tab.py index 696a52c7..80737363 100644 --- a/src/surface/gui/gui/widgets/debug_tab.py +++ b/src/surface/gui/gui/widgets/debug_tab.py @@ -12,6 +12,7 @@ QVBoxLayout, QWidget, ) +from gui.widgets.heartbeat import HeartbeatWidget class DebugWidget(QWidget): @@ -24,6 +25,9 @@ def __init__(self) -> None: alignment=Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft, ) + top_bar.addWidget(HeartbeatWidget(), alignment=Qt.AlignmentFlag.AlignTop | + Qt.AlignmentFlag.AlignLeft) + right_bar = QVBoxLayout() right_bar.addWidget(ThrusterTester()) right_bar.addWidget(Arm()) diff --git a/src/surface/gui/gui/widgets/flood_warning.py b/src/surface/gui/gui/widgets/flood_warning.py index 0a4aba50..30f4a4da 100644 --- a/src/surface/gui/gui/widgets/flood_warning.py +++ b/src/surface/gui/gui/widgets/flood_warning.py @@ -10,13 +10,17 @@ QLabel, QVBoxLayout, QWidget, + QHBoxLayout ) +from gui.widgets.circle import CircleIndicator + from rov_msgs.msg import Flooding class FloodWarning(QWidget): - signal: pyqtSignal = pyqtSignal(Flooding) + + signal = pyqtSignal(Flooding) def __init__(self) -> None: super().__init__() @@ -30,17 +34,23 @@ def __init__(self) -> None: # Create a latch variable self.warning_msg_latch: bool = False # Create basic 2 vertical stacked boxes layout - self.flood_layout = QVBoxLayout() + flood_layout = QVBoxLayout() # Create the label that tells us what this is - self.label = QLabel("Flooding Indicator") + + header_layout = QHBoxLayout() + label = QLabel('Flooding Status') font = QFont("Arial", 14) - self.label.setFont(font) - self.flood_layout.addWidget(self.label) + label.setFont(font) + header_layout.addWidget(label) + self.indicator_circle = CircleIndicator(radius=10) + header_layout.addWidget(self.indicator_circle) + + flood_layout.addLayout(header_layout) self.indicator = QLabel("No Water present") self.indicator.setFont(font) - self.flood_layout.addWidget(self.indicator) - self.setLayout(self.flood_layout) + flood_layout.addWidget(self.indicator) + self.setLayout(flood_layout) @pyqtSlot(Flooding) def refresh(self, msg: Flooding) -> None: @@ -48,8 +58,10 @@ def refresh(self, msg: Flooding) -> None: self.indicator.setText("FLOODING") self.subscription.get_logger().error("Robot is actively flooding, do something!") self.warning_msg_latch = True + self.indicator_circle.set_off() else: - self.indicator.setText("No Water present") + self.indicator.setText('No Water present') + self.indicator_circle.set_on() if self.warning_msg_latch: self.subscription.get_logger().warning("Robot flooding has reset itself.") self.warning_msg_latch = False diff --git a/src/surface/gui/gui/widgets/heartbeat.py b/src/surface/gui/gui/widgets/heartbeat.py new file mode 100644 index 00000000..c9f2392f --- /dev/null +++ b/src/surface/gui/gui/widgets/heartbeat.py @@ -0,0 +1,58 @@ +from gui.event_nodes.subscriber import GUIEventSubscriber +from gui.widgets.circle import CircleIndicator +from PyQt6.QtCore import pyqtSignal, pyqtSlot +from PyQt6.QtGui import QFont +from PyQt6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget + +from rov_msgs.msg import VehicleState + + +class HeartbeatWidget(QWidget): + + signal = pyqtSignal(VehicleState) + + def __init__(self) -> None: + super().__init__() + + self.signal.connect(self.refresh) + self.subscription = GUIEventSubscriber(VehicleState, 'vehicle_state_event', self.signal) + # Create a latch variable + self.warning_msg_latch: bool = False + + heartbeat_layout = QVBoxLayout() + + font = QFont("Arial", 14) + + pi_status_layout = QHBoxLayout() + self.pi_indicator = QLabel('No Pi Status') + self.pi_indicator.setFont(font) + pi_status_layout.addWidget(self.pi_indicator) + self.pi_indicator_circle = CircleIndicator(radius=10) + pi_status_layout.addWidget(self.pi_indicator_circle) + heartbeat_layout.addLayout(pi_status_layout) + + pixhawk_status_layout = QHBoxLayout() + self.pixhawk_indicator = QLabel('No Pixhawk Status') + self.pixhawk_indicator.setFont(font) + pixhawk_status_layout.addWidget(self.pixhawk_indicator) + self.pixhawk_indicator_circle = CircleIndicator(radius=10) + pixhawk_status_layout.addWidget(self.pixhawk_indicator_circle) + heartbeat_layout.addLayout(pixhawk_status_layout) + + self.setLayout(heartbeat_layout) + + @pyqtSlot(VehicleState) + def refresh(self, msg: VehicleState) -> None: + if msg.pi_connected: + self.pi_indicator.setText('Pi Connected') + self.pi_indicator_circle.set_on() + else: + self.pi_indicator.setText('Pi Disconnected') + self.pi_indicator_circle.set_off() + + if msg.pixhawk_connected: + self.pixhawk_indicator.setText('Pixhawk Connected') + self.pixhawk_indicator_circle.set_on() + else: + self.pixhawk_indicator.setText('Pixhawk Disconnected') + self.pixhawk_indicator_circle.set_off()