From 97c73b8869537918729f36fdf0d2a8b9d10f30ac Mon Sep 17 00:00:00 2001 From: Yekta Yazar Date: Wed, 6 Aug 2025 10:53:45 -0700 Subject: [PATCH 1/8] wip, added a circle to show the color of the curve in the control panel. set up signals to update the color of the circle. --- trace/widgets/control_panel.py | 34 +++++++++++++++++++++++++++++++++ trace/widgets/curve_settings.py | 5 ++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/trace/widgets/control_panel.py b/trace/widgets/control_panel.py index 8370f3fd..93787285 100644 --- a/trace/widgets/control_panel.py +++ b/trace/widgets/control_panel.py @@ -698,6 +698,12 @@ def __init__(self, plot_curve_item: ArchivePlotCurveItem, variable_name: str = N data_type_layout = QtWidgets.QHBoxLayout() second_layout.addLayout(data_type_layout) + self.color_circle_label = QtWidgets.QLabel() + circle_pixmap = self.create_color_circle(self.source.color_string, 12) + self.color_circle_label.setPixmap(circle_pixmap) + self.color_circle_label.setFixedSize(12, 12) + pv_settings_layout.addWidget(self.color_circle_label) + self.invalid_action = None self.variable_name_label = QtWidgets.QLabel() self.variable_name_label.setMinimumWidth(40) @@ -741,6 +747,21 @@ def __init__(self, plot_curve_item: ArchivePlotCurveItem, variable_name: str = N data_type_layout.addStretch() + def create_color_circle(self, color, size=12): + """Create a colored circle pixmap for the curve color indicator""" + pixmap = QtGui.QPixmap(size, size) + pixmap.fill(QtCore.Qt.transparent) + + painter = QtGui.QPainter(pixmap) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + + painter.setBrush(QtGui.QBrush(QtGui.QColor(color))) + painter.setPen(QtGui.QPen(QtCore.Qt.black, 1)) + painter.drawEllipse(0, 0, size-1, size-1) + painter.end() + + return pixmap + def update_variable_name(self): """Update the variable name label""" if self._variable_name: @@ -802,8 +823,21 @@ def update_archive_icon(self, connected: bool) -> None: def show_settings_modal(self): if self.pv_settings_modal is None: self.pv_settings_modal = CurveSettingsModal(self.pv_settings_button, self.plot, self.source) + self.pv_settings_modal.color_changed.connect(self.on_color_changed) self.pv_settings_modal.show() + @QtCore.Slot(object) + def on_color_changed(self, color): + """Handle color change from settings modal""" + self.update_color_circle() + + def update_color_circle(self): + """Update the color circle when the curve color changes""" + if hasattr(self, 'color_circle_label'): + curve_color = getattr(self.source, 'color_string', None) + circle_pixmap = self.create_color_circle(curve_color, 12) + self.color_circle_label.setPixmap(circle_pixmap) + def mousePressEvent(self, event: QtGui.QMouseEvent): if event.button() == QtCore.Qt.LeftButton and self.handle.geometry().contains(event.position().toPoint()): self.hide() # hide actual widget so it doesn't conflict with pixmap on cursor diff --git a/trace/widgets/curve_settings.py b/trace/widgets/curve_settings.py index ddf625af..b3613dc0 100644 --- a/trace/widgets/curve_settings.py +++ b/trace/widgets/curve_settings.py @@ -1,5 +1,5 @@ from qtpy.QtGui import QColor -from qtpy.QtCore import Qt, Slot +from qtpy.QtCore import Qt, Slot, Signal from qtpy.QtWidgets import QWidget, QCheckBox, QLineEdit, QVBoxLayout from pydm.widgets.archiver_time_plot import TimePlotCurveItem, PyDMArchiverTimePlot @@ -9,6 +9,8 @@ class CurveSettingsModal(QWidget): + color_changed = Signal(object) + def __init__(self, parent: QWidget, plot: PyDMArchiverTimePlot, curve: TimePlotCurveItem): super().__init__(parent) self.setWindowFlag(Qt.Popup) @@ -140,6 +142,7 @@ def set_curve_name(self): @Slot(QColor) def set_curve_color(self, color: QColor): self.curve.color = color + self.color_changed.emit(color) @Slot(object) def set_curve_type(self, curve_type: str | None = None) -> None: From b33e0080be5cc08a0bc780fcc81eee8ec2bcbeca Mon Sep 17 00:00:00 2001 From: Yekta Yazar Date: Fri, 29 Aug 2025 09:27:08 -0700 Subject: [PATCH 2/8] add assets, stylesheets and a theme manager to be able to switch between dark and light mode. also small changes to be able to run trace with pyside6 --- trace/assets/dark_icons/check.svg | 1 + trace/assets/dark_icons/circle-solid.svg | 1 + trace/assets/dark_icons/down-arrow.svg | 1 + trace/assets/dark_icons/left-arrow.svg | 1 + trace/assets/dark_icons/right-arrow.svg | 1 + trace/assets/dark_icons/up-arrow.svg | 1 + trace/assets/light_icons/check.svg | 1 + trace/assets/light_icons/circle-solid.svg | 1 + trace/assets/light_icons/down-arrow.svg | 1 + trace/assets/light_icons/left-arrow.svg | 1 + trace/assets/light_icons/right-arrow.svg | 1 + trace/assets/light_icons/up-arrow.svg | 1 + trace/main.py | 88 +++- trace/stylesheets/dark_mode.qss | 467 ++++++++++++++++++++++ trace/stylesheets/light_mode.qss | 331 +++++++++++++++ trace/theme_manager.py | 384 ++++++++++++++++++ trace/toggle.py | 189 +++++++++ trace/widgets/control_panel.py | 157 +++++--- trace/widgets/frozen_table_view.py | 10 +- 19 files changed, 1565 insertions(+), 73 deletions(-) create mode 100644 trace/assets/dark_icons/check.svg create mode 100644 trace/assets/dark_icons/circle-solid.svg create mode 100644 trace/assets/dark_icons/down-arrow.svg create mode 100644 trace/assets/dark_icons/left-arrow.svg create mode 100644 trace/assets/dark_icons/right-arrow.svg create mode 100644 trace/assets/dark_icons/up-arrow.svg create mode 100644 trace/assets/light_icons/check.svg create mode 100644 trace/assets/light_icons/circle-solid.svg create mode 100644 trace/assets/light_icons/down-arrow.svg create mode 100644 trace/assets/light_icons/left-arrow.svg create mode 100644 trace/assets/light_icons/right-arrow.svg create mode 100644 trace/assets/light_icons/up-arrow.svg create mode 100644 trace/stylesheets/dark_mode.qss create mode 100644 trace/stylesheets/light_mode.qss create mode 100644 trace/theme_manager.py create mode 100644 trace/toggle.py diff --git a/trace/assets/dark_icons/check.svg b/trace/assets/dark_icons/check.svg new file mode 100644 index 00000000..f86123b4 --- /dev/null +++ b/trace/assets/dark_icons/check.svg @@ -0,0 +1 @@ + diff --git a/trace/assets/dark_icons/circle-solid.svg b/trace/assets/dark_icons/circle-solid.svg new file mode 100644 index 00000000..5db60c8c --- /dev/null +++ b/trace/assets/dark_icons/circle-solid.svg @@ -0,0 +1 @@ + diff --git a/trace/assets/dark_icons/down-arrow.svg b/trace/assets/dark_icons/down-arrow.svg new file mode 100644 index 00000000..bd1fa80e --- /dev/null +++ b/trace/assets/dark_icons/down-arrow.svg @@ -0,0 +1 @@ + diff --git a/trace/assets/dark_icons/left-arrow.svg b/trace/assets/dark_icons/left-arrow.svg new file mode 100644 index 00000000..5de41a85 --- /dev/null +++ b/trace/assets/dark_icons/left-arrow.svg @@ -0,0 +1 @@ + diff --git a/trace/assets/dark_icons/right-arrow.svg b/trace/assets/dark_icons/right-arrow.svg new file mode 100644 index 00000000..ade35586 --- /dev/null +++ b/trace/assets/dark_icons/right-arrow.svg @@ -0,0 +1 @@ + diff --git a/trace/assets/dark_icons/up-arrow.svg b/trace/assets/dark_icons/up-arrow.svg new file mode 100644 index 00000000..081ad784 --- /dev/null +++ b/trace/assets/dark_icons/up-arrow.svg @@ -0,0 +1 @@ + diff --git a/trace/assets/light_icons/check.svg b/trace/assets/light_icons/check.svg new file mode 100644 index 00000000..f79a44e5 --- /dev/null +++ b/trace/assets/light_icons/check.svg @@ -0,0 +1 @@ + diff --git a/trace/assets/light_icons/circle-solid.svg b/trace/assets/light_icons/circle-solid.svg new file mode 100644 index 00000000..3376733f --- /dev/null +++ b/trace/assets/light_icons/circle-solid.svg @@ -0,0 +1 @@ + diff --git a/trace/assets/light_icons/down-arrow.svg b/trace/assets/light_icons/down-arrow.svg new file mode 100644 index 00000000..edfbf88f --- /dev/null +++ b/trace/assets/light_icons/down-arrow.svg @@ -0,0 +1 @@ + diff --git a/trace/assets/light_icons/left-arrow.svg b/trace/assets/light_icons/left-arrow.svg new file mode 100644 index 00000000..4eac8e8b --- /dev/null +++ b/trace/assets/light_icons/left-arrow.svg @@ -0,0 +1 @@ + diff --git a/trace/assets/light_icons/right-arrow.svg b/trace/assets/light_icons/right-arrow.svg new file mode 100644 index 00000000..2aeebaff --- /dev/null +++ b/trace/assets/light_icons/right-arrow.svg @@ -0,0 +1 @@ + diff --git a/trace/assets/light_icons/up-arrow.svg b/trace/assets/light_icons/up-arrow.svg new file mode 100644 index 00000000..3229308e --- /dev/null +++ b/trace/assets/light_icons/up-arrow.svg @@ -0,0 +1 @@ + diff --git a/trace/main.py b/trace/main.py index 6fa8baeb..b5a3c003 100644 --- a/trace/main.py +++ b/trace/main.py @@ -5,9 +5,9 @@ from getpass import getuser from datetime import datetime -import qtawesome as qta -from qtpy.QtGui import QFont, QImage, QKeySequence -from qtpy.QtCore import Qt, Slot, QSize, Signal, QBuffer, QIODevice +from qtpy.QtGui import QFont, QImage, QKeySequence, QColor +from qtpy.QtCore import Qt, Slot, QSize, Signal, QBuffer, QIODevice, QSettings +from theme_manager import Theme, IconColors, ThemeManager from qtpy.QtWidgets import ( QMenu, QLabel, @@ -46,8 +46,21 @@ class TraceDisplay(Display): def __init__(self, parent=None, args=None, macros=None) -> None: super(TraceDisplay, self).__init__(parent=parent, args=args, macros=macros, ui_filename=None) + + app = QApplication.instance() + if not app.main_window: + return + + self.theme_manager = ThemeManager( + app, + light_stylesheet_path="stylesheets/light_mode.qss", + dark_stylesheet_path="stylesheets/dark_mode.qss", + ) + settings = QSettings() + self.is_dark_mode = settings.value("isDarkTheme", False, type=bool) self.build_ui() - self.configure_app() + self.configure_app(app) + self.setup_icons() self.resize(1000, 600) # Set plot's timerange after the UI is built @@ -77,7 +90,7 @@ def build_ui(self) -> None: # Create the plotting and control widgets plot_side_widget = self.build_plot_side(self) - self.control_panel = ControlPanel() + self.control_panel = ControlPanel(theme_manager=self.theme_manager) self.control_panel.layout().setContentsMargins(8, 0, 0, 0) self.control_panel.plot = self.plot self.control_panel.curve_list_changed.connect(self.data_insight_tool.update_pv_select_box) @@ -89,15 +102,7 @@ def build_ui(self) -> None: main_splitter.setCollapsible(0, False) main_splitter.setStretchFactor(0, 1) main_splitter.setHandleWidth(10) - main_splitter.setStyleSheet( - "\n".join( - [ - "QSplitter::handle {", - " background-color: white;", - "}", - ] - ) - ) + main_layout.addWidget(main_splitter) # Create the footer section of the app @@ -113,10 +118,11 @@ def build_plot_side(self, parent): toolbar = self.build_toolbar(plot_side_widget) plot_side_layout.addWidget(toolbar) - # Create plot + background_color = "#1E1E1E" if self.theme_manager.get_current_theme() == Theme.DARK else "white" + self.plot = PyDMArchiverTimePlot( plot_side_widget, - background="white", + background=background_color, optimized_data_bins=5000, cache_data=False, show_all=False, @@ -131,7 +137,6 @@ def build_plot_side(self, parent): self.data_insight_tool.plot = self.plot self.settings_button = QPushButton(self.plot) - self.settings_button.setIcon(qta.icon("msc.settings-gear")) self.settings_button.setFlat(True) self.plot_settings = PlotSettingsModal(self.settings_button, self.plot) @@ -251,7 +256,29 @@ def configure_app(self): app = QApplication.instance() if not app.main_window: return + + def setup_icons(self): + """Set up all icons after theme manager is initialized""" + self.settings_button.setIcon(self.theme_manager.create_icon("msc.settings-gear", IconColors.PRIMARY)) + + def on_theme_changed(self, theme: Theme): + """Handle theme changes - update icons and button text""" + if theme == Theme.DARK: + self.theme_toggle_button.setText("Light Mode") + icon = self.theme_manager.create_icon("fa.sun-o", IconColors.PRIMARY) + else: + self.theme_toggle_button.setText("Dark Mode") + icon = self.theme_manager.create_icon("fa.moon-o", IconColors.PRIMARY) + + if icon: + self.theme_toggle_button.setIcon(icon) + settings_icon = self.theme_manager.create_icon("msc.settings-gear", IconColors.PRIMARY) + if settings_icon: + self.settings_button.setIcon(settings_icon) + + def configure_app(self, app): + """UI changes to be made to the PyDMApplication""" # Hide navigation bar by default (can be shown in menu bar) app.main_window.toggle_nav_bar(False) app.main_window.ui.actionShow_Navigation_Bar.setChecked(False) @@ -301,9 +328,36 @@ def construct_trace_menu(self, parent: QMenuBar) -> QMenu: fetch_archive.setShortcut(QKeySequence("Ctrl+F")) dit_action = menu.addAction("Data Insight Tool...", self.data_insight_tool.show) dit_action.setShortcut(QKeySequence("Ctrl+D")) + + menu.addSeparator() + + if self.is_dark_mode: + self.theme_action = menu.addAction("Switch to Light Mode", self.toggle_theme) + else: + self.theme_action = menu.addAction("Switch to Dark Mode", self.toggle_theme) + + self.theme_action.setShortcut(QKeySequence("Ctrl+T")) return menu + def toggle_theme(self): + """Toggle between dark and light mode.""" + if self.is_dark_mode: + self.theme_manager.set_theme(Theme.LIGHT) + self.theme_action.setText("Switch to Dark Mode") + self.plot.setBackgroundColor(QColor("#FFFFFF")) + self.setup_icons() + self.is_dark_mode = False + else: + self.theme_manager.set_theme(Theme.DARK) + self.theme_action.setText("Switch to Light Mode") + self.plot.setBackgroundColor(QColor("#1E1E1E")) + self.setup_icons() + self.is_dark_mode = True + + QApplication.processEvents() + self.repaint() + @Slot() def save_plot_image(self) -> None: """Saves current plot as an image. Opens file dialog to allow user to diff --git a/trace/stylesheets/dark_mode.qss b/trace/stylesheets/dark_mode.qss new file mode 100644 index 00000000..65e02db4 --- /dev/null +++ b/trace/stylesheets/dark_mode.qss @@ -0,0 +1,467 @@ +/* Global Styles */ +QWidget { + background-color: #1E1E1E; + font-family: "Arial, sans-serif"; + font-size: 10px; + color: #E0E0E0; +} + +/* Frames */ +QFrame[type="section"] { + background-color: #252525; + border: 1px solid #3C3C3C; + border-radius: 4px; + padding: 6px; +} + +QFrame[type="panel"] { + background-color: #2A2A2A; + border: 1px solid #444444; + border-radius: 4px; + padding: 8px; +} + +/* Menu Bar */ +QMenuBar { + background-color: #2C2C2C; + border-bottom: 1px solid #3A3A3A; + padding: 2px; +} + +QMenuBar::item { + padding: 4px 8px; + background: transparent; + border-radius: 2px; +} + +QMenuBar::item:selected { + background-color: #444444; +} + +QMenu { + background-color: #2C2C2C; + border: 1px solid #444444; +} + +QMenu::item { + padding: 4px 20px; + color: #E0E0E0; +} + +QMenu::item:selected { + background-color: #3A3A3A; + color: #FFFFFF; +} + +/* Buttons */ +QPushButton, PyDMPushButton { + background-color: #2F2F2F; + color: #E0E0E0; + text-align: center; + border: 1px solid #5A5A5A; + border-radius: 4px; + /* min-width: 50px; */ + min-height: 20px; + padding: 2px 8px; +} + +QPushButton:hover, PyDMPushButton:hover { + background-color: #4A4A4A; + color: #FFFFFF; + border: 1px solid #7A7A7A; +} + +QPushButton:pressed, PyDMPushButton:pressed { + background-color: #3A3A3A; + color: #FFFFFF; + border: 1px solid #666666; + padding-top: 7px; + padding-bottom: 5px; +} + +QPushButton:disabled, PyDMPushButton:disabled { + background-color: #2F2F2F; + color: #7A7A7A; + border: 1px solid #444444; + cursor: not-allowed; +} + +/* Icon Buttons */ +QPushButton[flat="true"] { + background: transparent; + border: none; +} + +QPushButton[flat="true"]::icon { + color: #E0E0E0; +} + +QPushButton[flat="true"]:hover { + background-color: #333333; +} + +QPushButton[flat="true"]:pressed { + background-color: #2A2A2A; +} + +QPushButton QIcon { + color: #E0E0E0; +} + +/* Time Range Buttons */ +QPushButton[timeRange=true] { + background-color: #2F2F2F; + border: 1px solid #555555; +} + +QPushButton[timeRange=true]:checked { + background-color: #555555; + color: #FFFFFF; +} + +/* Plot Button */ +QPushButton#plotButton { + background-color: #444444; + border: 1px solid #666666; +} + +QPushButton#plotButton:hover { + background-color: #555555; +} + +QPushButton#plotButton:pressed { + background-color: #333333; +} + +/* Line Edits */ +QLineEdit, PyDMLineEdit { + background-color: #2A2A2A; + color: #E0E0E0; + border: 1px solid #5A5A5A; + border-radius: 4px; + padding: 4px 6px; +} + +QLineEdit:disabled, PyDMLineEdit:disabled { + color: #7A7A7A; + background-color: #2F2F2F; + border: 1px solid #444444; +} + +/* Group Boxes */ +QGroupBox { + border: 1px solid #5A5A5A; + border-radius: 5px; + margin-top: 6px; +} + +QGroupBox::title { + subcontrol-origin: margin; + left: 8px; + padding: 0 4px; +} + +/* Collapsible Sections */ +QWidget[collapsible=true] { + border: 1px solid #5A5A5A; + margin: 6px; + padding: 4px; +} + +QLabel[sectionHeader=true] { + font-weight: bold; + font-size: 11px; + color: #E0E0E0; +} + +/* PV Labels */ +PyDMLabel[pvName=true] { + color: #00FFD0; +} + +/* Check Boxes */ +QCheckBox { + spacing: 6px; +} + +QCheckBox::indicator:unchecked { + background-color: #2A2A2A; +} + +QCheckBox::indicator { + width: 16px; + height: 16px; + border: 1px solid #5A5A5A; + background-color: #2A2A2A; + border-radius: 2px; +} + +QCheckBox::indicator:checked { + background-color: #3A3A3A; + image: url("assets/dark_icons/check.svg"); +} + +QCheckBox::indicator:hover { + border: 1px solid #7A7A7A; + background-color: #4A4A4A; +} + +/* Sliders */ +QSlider::handle:horizontal { + background: #AAAAAA; + border: 1px solid #777777; + /*width: 14px; + height: 14px;*/ + margin: -4px 0; + border-radius: 7px; +} + +QSlider::sub-page:horizontal { + background: #5EA7FF; + border: 1px solid #444444; + height: 6px; + border-radius: 3px; +} + +QSlider::add-page:horizontal { + background: #2F2F2F; + border: 1px solid #444444; + height: 6px; + border-radius: 3px; +} + +/* Toggle Switches */ +QSwitch, PyDMSwitch { + background-color: #2F2F2F; + border: 1px solid #666666; +} + +/* PyDMChannel Indicators */ +PyDMChannel[connected=true] { + background-color: #006600; +} + +PyDMChannel[connected=false] { + background-color: #660000; +} + +/* Live/Archive Indicators */ +QLabel[status="live"] { + color: #00CC00; + font-weight: bold; +} + +QLabel[status="archive"] { + color: #FF9900; + font-weight: bold; +} + +/* Plot Areas */ +PyDMTimePlot, PyDMWaveformPlot { + background-color: #1E1E1E; + border: 1px solid #555555; +} + +/* Plot Settings Panel */ +QFrame#plotSettings { + background-color: #2A2A2A; + border: 1px solid #555555; +} + +QFrame#plotSettings QLabel { + color: #E0E0E0; +} + +/* Status Bar */ +QStatusBar { + background-color: #2C2C2C; + border-top: 1px solid #444444; +} + +/* Tooltips */ +QToolTip { + background-color: #333333; + color: #FFFFFF; + border: 1px solid #888888; + padding: 4px; + font-size: 10px; +} + +/* Scrollbars */ +QScrollBar:vertical { + background: #1E1E1E; + width: 10px; + margin: 18px 0; +} + +QScrollBar::handle:vertical { + background: #2F2F2F; + min-height: 20px; + border: 1px solid #5A5A5A; + border-radius: 5px; +} + +QScrollBar::handle:vertical:hover { + background: #4A4A4A; + border: 1px solid #7A7A7A; +} + +QScrollBar::sub-line:vertical, +QScrollBar::add-line:vertical { + background: #2F2F2F; + height: 16px; + border: 1px solid #5A5A5A; +} + +QScrollBar::sub-line:vertical:hover, +QScrollBar::add-line:vertical:hover { + background: #4A4A4A; + border: 1px solid #7A7A7A; +} + +QScrollBar::sub-line:vertical { + subcontrol-position: top; + subcontrol-origin: margin; + image: url("assets/dark_icons/up-arrow.svg"); +} + +QScrollBar::add-line:vertical { + subcontrol-position: bottom; + subcontrol-origin: margin; + image: url("assets/dark_icons/down-arrow.svg"); +} + +QScrollBar:horizontal { + background: #1E1E1E; + height: 10px; + margin: 0 18px; +} + +QScrollBar::handle:horizontal { + background: #2F2F2F; + min-width: 20px; + border: 1px solid #5A5A5A; + border-radius: 5px; +} + +QScrollBar::handle:horizontal:hover { + background: #4A4A4A; + border: 1px solid #7A7A7A; +} + +QScrollBar::sub-line:horizontal, +QScrollBar::add-line:horizontal { + background: #2F2F2F; + width: 16px; + border: 1px solid #5A5A5A; +} + +QScrollBar::sub-line:horizontal:hover, +QScrollBar::add-line:horizontal:hover { + background: #4A4A4A; + border: 1px solid #7A7A7A; +} + +QScrollBar::sub-line:horizontal { + subcontrol-position: left; + subcontrol-origin: margin; + image: url("assets/dark_icons/left-arrow.svg"); +} + +QScrollBar::add-line:horizontal { + subcontrol-position: right; + subcontrol-origin: margin; + image: url("assets/dark_icons/right-arrow.svg"); +} + +/* Radio Buttons */ +QRadioButton { + spacing: 6px; +} + +QRadioButton::indicator { + width: 16px; + height: 16px; + border: 1px solid #777777; + background-color: #2F2F2F; + border-radius: 8px; +} + +QRadioButton::indicator:checked { + background-color: #2F2F2F; + image: url("assets/dark_icons/circle-solid.svg"); +} + +QRadioButton::indicator:unchecked { + background-color: #2A2A2A; +} + +QRadioButton::indicator:hover { + border-color: #AAAAAA; +} + +/* Combo Box */ +QComboBox { + background-color: #2A2A2A; + border: 1px solid #5A5A5A; + border-radius: 4px; + padding: 4px; + color: #E0E0E0; +} + +QComboBox::drop-down { + background-color: #2A2A2A; + border-left: 1px solid #5A5A5A; + margin: 0px; +} + +QComboBox::down-arrow { + image: url("assets/dark_icons/down-arrow.svg"); + width: 10px; + height: 10px; +} + +QComboBox QAbstractItemView { + background-color: #2A2A2A; + color: #E0E0E0; +/* border: 1px solid #5A5A5A;*/ + selection-background-color: #4A4A4A; + selection-color: #FFFFFF; +} + +/*SpinBox */ +QSpinBox { + background-color: #2A2A2A; + color: #E0E0E0; + border: 1px solid #5A5A5A; + border-radius: 4px; +} + +QSpinBox::up-button, QSpinBox::down-button { + background-color: #2A2A2A; + border: 1px solid #5A5A5A; + width: 16px; +} + +QSpinBox::up-button { + subcontrol-position: top right; + border-top-right-radius: 4px; +} + +QSpinBox::down-button { + subcontrol-position: bottom right; + border-bottom-right-radius: 4px; +} + +QSpinBox::up-arrow { + image: url("assets/dark_icons/up-arrow.svg"); + width: 8px; + height: 8px; + color: #5EA7FF; +} + +QSpinBox::down-arrow { + image: url("assets/dark_icons/down-arrow.svg"); + width: 8px; + height: 8px; +} diff --git a/trace/stylesheets/light_mode.qss b/trace/stylesheets/light_mode.qss new file mode 100644 index 00000000..a0a1274c --- /dev/null +++ b/trace/stylesheets/light_mode.qss @@ -0,0 +1,331 @@ +/* Global Styles */ +QWidget { + background-color: #FAFAFA; + font-family: "Arial, sans-serif"; + font-size: 10px; + color: #202020; +} + +/* Frames */ +QFrame[type="section"] { + background-color: #F4F4F4; + border: 1px solid #CCCCCC; + border-radius: 4px; + padding: 6px; +} + +QFrame[type="panel"] { + background-color: #F8F8F8; + border: 1px solid #BBBBBB; + border-radius: 4px; + padding: 8px; +} + +/* Menu Bar */ +QMenuBar { + background-color: #F2F2F2; + border-bottom: 1px solid #D0D0D0; + padding: 2px; +} + +QMenuBar::item { + padding: 4px 8px; + background: transparent; + border-radius: 2px; +} + +QMenuBar::item:selected { + background-color: #DADADA; +} + +QMenu { + background-color: #FFFFFF; + border: 1px solid #CCCCCC; +} + +QMenu::item { + padding: 4px 20px; + color: #202020; +} + +QMenu::item:selected { + background-color: #E5E5E5; + color: #000000; +} + +/* Buttons */ +QPushButton, PyDMPushButton { + background-color: #E6E6E6; + color: #202020; + text-align: center; + border: 1px solid #CCCCCC; + border-radius: 4px; + /* min-width: 50px; */ + min-height: 20px; + padding: 2px 8px; +} + +QPushButton:hover, PyDMPushButton:hover { + background-color: #DADADA; + color: #000000; + border: 1px solid #AAAAAA; +} + +QPushButton:pressed, PyDMPushButton:pressed { + background-color: #CFCFCF; + border: 1px solid #999999; + padding-top: 7px; + padding-bottom: 5px; +} + +QPushButton:disabled, PyDMPushButton:disabled { + background-color: #F0F0F0; + color: #AAAAAA; + border: 1px solid #DDDDDD; +} + +/* Flat Icon Buttons */ +QPushButton[flat="true"] { + background: transparent; + border: none; +} + +QPushButton[flat="true"]:hover { + background-color: #EEEEEE; +} + +QPushButton[flat="true"]:pressed { + background-color: #DDDDDD; +} + +/* Line Edits */ +QLineEdit, PyDMLineEdit { + background-color: #FFFFFF; + color: #202020; + border: 1px solid #CCCCCC; + border-radius: 4px; + padding: 4px 6px; +} + +QLineEdit:disabled, PyDMLineEdit:disabled { + color: #AAAAAA; + background-color: #F4F4F4; + border: 1px solid #DDDDDD; +} + +/* Labels */ +PyDMLabel[pvName=true] { + color: #007ACC; +} + +/* Checkboxes */ +QCheckBox { + spacing: 6px; + color: red; +} + +QCheckBox::indicator { + width: 16px; + height: 16px; + border: 1px solid #CCCCCC; + background-color: #FFFFFF; + border-radius: 2px; +} + +QCheckBox::indicator:checked { + background-color: #D0D0D0; + image: url("assets/light_icons/check.svg"); + color: red; +} + +QCheckBox::indicator:hover { + border: 1px solid #AAAAAA; + background-color: #EEEEEE; +} + +/* Radio Buttons */ +QRadioButton { + spacing: 6px; + color: #202020; +} + +QRadioButton::indicator { + width: 16px; + height: 16px; + border: 1px solid #AAAAAA; + background-color: #FFFFFF; + border-radius: 8px; +} + +QRadioButton::indicator:checked { + background-color: #C8DAF0; + image: url("assets/light_icons/circle-solid.svg"); +} + +QRadioButton::indicator:hover { + border-color: #888888; +} + +/* Sliders */ +QSlider::handle:horizontal { + background: #777777; + border: 1px solid #555555; + margin: -4px 0; + border-radius: 7px; +} + +QSlider::sub-page:horizontal { + background: #4A86E8; + border: 1px solid #888888; + height: 6px; + border-radius: 3px; +} + +QSlider::add-page:horizontal { + background: #E0E0E0; + border: 1px solid #CCCCCC; + height: 6px; + border-radius: 3px; +} + +/* ComboBox */ +QComboBox { + background-color: #FFFFFF; + border: 1px solid #CCCCCC; + border-radius: 4px; + padding: 4px; + color: #202020; +} + +QComboBox::drop-down { + background-color: #FFFFFF; + border-left: 1px solid #CCCCCC; + margin: 0px; +} + +QComboBox::down-arrow { + image: url("assets/light_icons/down-arrow.svg"); + width: 10px; + height: 10px; +} + +QComboBox QAbstractItemView { + background-color: #FFFFFF; + color: #202020; + selection-background-color: #DADADA; + selection-color: #000000; +} + +/* Scrollbars */ +QScrollBar:vertical { + background: #F2F2F2; + width: 10px; + margin: 18px 0; +} + +QScrollBar::handle:vertical { + background: #C8C8C8; + min-height: 20px; + border: 1px solid #CCCCCC; + border-radius: 5px; +} + +QScrollBar::handle:vertical:hover { + background: #AAAAAA; + border: 1px solid #7A7A7A; +} + +QScrollBar::sub-line:vertical, +QScrollBar::add-line:vertical { + background: #DDDDDD; + height: 16px; + border: 1px solid #CCCCCC; +} + +QScrollBar::sub-line:vertical:hover, +QScrollBar::add-line:vertical:hover { + background: #AAAAAA; + border: 1px solid #7A7A7A; +} + +QScrollBar::sub-line:vertical { + subcontrol-position: top; + subcontrol-origin: margin; + image: url("assets/light_icons/up-arrow.svg"); +} + +QScrollBar::add-line:vertical { + subcontrol-position: bottom; + subcontrol-origin: margin; + image: url("assets/light_icons/down-arrow.svg"); +} + +QScrollBar:horizontal { + background: #F2F2F2; + height: 10px; + margin: 0 18px; +} + +QScrollBar::handle:horizontal { + background: #C8C8C8; + min-width: 20px; + border: 1px solid #CCCCCC; + border-radius: 5px; +} + +QScrollBar::handle:horizontal:hover { + background: #AAAAAA; + border: 1px solid #7A7A7A; +} + +QScrollBar::sub-line:horizontal, +QScrollBar::add-line:horizontal { + background: #DDDDDD; + width: 16px; + border: 1px solid #CCCCCC; +} + +QScrollBar::sub-line:horizontal:hover, +QScrollBar::add-line:horizontal:hover { + background: #AAAAAA; + border: 1px solid #7A7A7A; +} + +QScrollBar::sub-line:horizontal { + subcontrol-position: left; + subcontrol-origin: margin; + image: url("assets/light_icons/left-arrow.svg"); +} + +QScrollBar::add-line:horizontal { + subcontrol-position: right; + subcontrol-origin: margin; + image: url("assets/light_icons/right-arrow.svg"); +} + +/* SpinBox */ +QSpinBox { + background-color: #FFFFFF; + color: #202020; + border: 1px solid #CCCCCC; + border-radius: 4px; +} + +QSpinBox::up-button, QSpinBox::down-button { + background-color: #FFFFFF; + border: 1px solid #CCCCCC; + width: 16px; +} + +QSpinBox::up-arrow { + image: url("assets/light_icons/up-arrow.svg"); + width: 8px; + height: 8px; +} + +QSpinBox::down-arrow { + image: url("assets/light_icons/down-arrow.svg"); + width: 8px; + height: 8px; +} + diff --git a/trace/theme_manager.py b/trace/theme_manager.py new file mode 100644 index 00000000..74198b25 --- /dev/null +++ b/trace/theme_manager.py @@ -0,0 +1,384 @@ +from __future__ import annotations +from enum import Enum + +from qtpy.QtWidgets import QApplication, QPushButton, QStyleFactory +from qtpy.QtCore import QObject, Signal, QSettings +from qtpy.QtGui import QPalette, QColor, QIcon + +import qtawesome as qta + +type ColorHex = str +type IconColorDict = dict[str, ColorHex] +type ButtonIconInfo = tuple[str, QPushButton, str, str] + + +class Theme(Enum): + """Theme enumeration for light and dark modes.""" + LIGHT = "light" + DARK = "dark" + + +class IconColors: + """Constants for icon color types.""" + PRIMARY: str = 'primary' + SECONDARY: str = 'secondary' + ACCENT: str = 'accent' + SUCCESS: str = 'success' + WARNING: str = 'warning' + ERROR: str = 'error' + DISABLED: str = 'disabled' + + +class ThemeManager(QObject): + """ + theme manager for Qt applications with icon support. + + Manages both Qt palette themes and icon colors, providing a unified + interface for light/dark mode switching with persistent settings. + + Attributes + ---------- + theme_changed : Signal + Signal emitted when theme changes, passes Theme enum value. + current_theme : Theme + Currently active theme. + app : QApplication + Qt application instance. + light_palette : QPalette + Palette configuration for light theme. + dark_palette : QPalette + Palette configuration for dark theme. + light_icon_colors : IconColorDict + Icon color mapping for light theme. + dark_icon_colors : IconColorDict + Icon color mapping for dark theme. + """ + + theme_changed = Signal(Theme) + + def __init__(self, app: QApplication, parent: QObject | None = None, light_stylesheet_path: str | None = None, dark_stylesheet_path: str | None = None +) -> None: + """ + Initialize the integrated theme manager. + + Parameters + ---------- + app : QApplication + The Qt application instance to manage themes for. + parent : QObject | None, optional + Parent QObject for memory management, by default None. + light_stylesheet_path : str | None, optional + Path to the light theme QSS stylesheet file, by default None. + dark_stylesheet_path : str | None, optional + Path to the dark theme QSS stylesheet file, by default None. + + Example + -------- + >>> app = QApplication(sys.argv) + >>> theme_manager = IntegratedThemeManager(app) + >>> theme_manager.set_theme(Theme.DARK) + """ + super().__init__(parent) + self.app = app + self.current_theme = Theme.LIGHT + + self.light_stylesheet_path = light_stylesheet_path + self.dark_stylesheet_path = dark_stylesheet_path + + self.app.setStyle(QStyleFactory.create("Fusion")) + + self._setup_palettes() + self._setup_icon_colors() + + # Load saved theme preference + settings = QSettings() + is_dark = settings.value("isDarkTheme", False, bool) + self.set_theme(Theme.DARK if is_dark else Theme.LIGHT) + + def _setup_palettes(self) -> None: + """ + Setup Qt palettes for light and dark themes. + + Creates and configures QPalette objects with appropriate colors + for both light and dark themes, including disabled state colors. + """ + # Light palette + self.light_palette = QPalette() + self.light_palette.setColor(QPalette.ColorRole.Window, QColor(240, 240, 240)) + self.light_palette.setColor(QPalette.ColorRole.WindowText, QColor(0, 0, 0)) + self.light_palette.setColor(QPalette.ColorRole.Base, QColor(255, 255, 255)) + self.light_palette.setColor(QPalette.ColorRole.AlternateBase, QColor(233, 233, 233)) + self.light_palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(255, 255, 255)) + self.light_palette.setColor(QPalette.ColorRole.ToolTipText, QColor(0, 0, 0)) + self.light_palette.setColor(QPalette.ColorRole.Text, QColor(0, 0, 0)) + self.light_palette.setColor(QPalette.ColorRole.Button, QColor(240, 240, 240)) + self.light_palette.setColor(QPalette.ColorRole.ButtonText, QColor(0, 0, 0)) + self.light_palette.setColor(QPalette.ColorRole.BrightText, QColor(255, 0, 0)) + self.light_palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218)) + self.light_palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218)) + self.light_palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255)) + + # Dark palette + self.dark_palette = QPalette() + self.dark_palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53)) + self.dark_palette.setColor(QPalette.ColorRole.WindowText, QColor(255, 255, 255)) + self.dark_palette.setColor(QPalette.ColorRole.Base, QColor(25, 25, 25)) + self.dark_palette.setColor(QPalette.ColorRole.AlternateBase, QColor(53, 53, 53)) + self.dark_palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(0, 0, 0)) + self.dark_palette.setColor(QPalette.ColorRole.ToolTipText, QColor(255, 255, 255)) + self.dark_palette.setColor(QPalette.ColorRole.Text, QColor(255, 255, 255)) + self.dark_palette.setColor(QPalette.ColorRole.Button, QColor(53, 53, 53)) + self.dark_palette.setColor(QPalette.ColorRole.ButtonText, QColor(255, 255, 255)) + self.dark_palette.setColor(QPalette.ColorRole.BrightText, QColor(255, 0, 0)) + self.dark_palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218)) + self.dark_palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218)) + self.dark_palette.setColor(QPalette.ColorRole.HighlightedText, QColor(0, 0, 0)) + + # Disabled colors for both palettes + disabled_light_color = QColor(120, 120, 120) + self.light_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled_light_color) + self.light_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_light_color) + self.light_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_light_color) + + disabled_dark_color = QColor(120, 120, 120) + self.dark_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled_dark_color) + self.dark_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_dark_color) + self.dark_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_dark_color) + + def _setup_icon_colors(self) -> None: + """ + Setup icon color schemes for light and dark themes. + + Defines color mappings for different icon types (primary, secondary, etc.) + optimized for visibility and accessibility in both light and dark themes. + """ + self.light_icon_colors: IconColorDict = { + IconColors.PRIMARY: '#000000', # Black for primary icons + IconColors.SECONDARY: '#666666', # Dark gray for secondary icons + IconColors.ACCENT: '#0078d4', # Blue for accent colors + IconColors.SUCCESS: '#107c10', # Green for success + IconColors.WARNING: '#ff8c00', # Orange for warnings + IconColors.ERROR: '#d13438', # Red for errors + IconColors.DISABLED: '#999999', # Light gray for disabled + } + + self.dark_icon_colors: IconColorDict = { + IconColors.PRIMARY: '#ffffff', # White for primary icons + IconColors.SECONDARY: '#cccccc', # Light gray for secondary icons + IconColors.ACCENT: '#0078d4', # Blue for accent colors + IconColors.SUCCESS: '#107c10', # Green for success + IconColors.WARNING: '#ff8c00', # Orange for warnings + IconColors.ERROR: '#d13438', # Red for errors + IconColors.DISABLED: '#666666', # Dark gray for disabled + } + + def _load_stylesheet(self, stylesheet_path: str | None) -> str: + """ + Load a QSS stylesheet from file. + + Parameters + ---------- + stylesheet_path : str | None + Path to the QSS stylesheet file, or None to return empty string. + + Returns + ------- + str + The stylesheet content, or empty string if file cannot be loaded. + + Examples + -------- + >>> stylesheet = theme_manager._load_stylesheet("styles/dark.qss") + """ + if not stylesheet_path: + return "" + + try: + with open(stylesheet_path, 'r', encoding='utf-8') as file: + return file.read() + except FileNotFoundError: + print(f"Warning: Stylesheet file not found: {stylesheet_path}") + return "" + except UnicodeDecodeError: + print(f"Warning: Could not decode stylesheet file: {stylesheet_path}") + try: + # Try with different encoding + with open(stylesheet_path, 'r', encoding='latin-1') as file: + return file.read() + except Exception as e: + print(f"Warning: Failed to load stylesheet {stylesheet_path}: {e}") + return "" + except Exception as e: + print(f"Warning: Failed to load stylesheet {stylesheet_path}: {e}") + return "" + + def set_theme(self, theme: Theme) -> None: + """ + Set the application theme. + + Parameters + ---------- + theme : Theme + The theme to apply (Theme.LIGHT or Theme.DARK). + + Examples + -------- + >>> theme_manager.set_theme(Theme.DARK) + >>> theme_manager.set_theme(Theme.LIGHT) + """ + self.current_theme = theme + + if theme == Theme.DARK: + self.app.setPalette(self.dark_palette) + stylesheet = self._load_stylesheet(self.dark_stylesheet_path) + else: + self.app.setPalette(self.light_palette) + stylesheet = self._load_stylesheet(self.light_stylesheet_path) + + self.app.setStyleSheet(stylesheet) + + settings = QSettings() + settings.setValue("isDarkTheme", theme == Theme.DARK) + + self.theme_changed.emit(theme) + + def toggle_theme(self) -> None: + """ + Toggle between light and dark themes. + + Switches from light to dark or dark to light, whichever is opposite + to the current theme. + + Example + -------- + >>> theme_manager.toggle_theme() # Switches to opposite theme + """ + new_theme = Theme.DARK if self.current_theme == Theme.LIGHT else Theme.LIGHT + self.set_theme(new_theme) + + def get_current_theme(self) -> Theme: + """ + Get the current theme. + + Returns + ------- + Theme + The currently active theme. + """ + return self.current_theme + + def get_icon_color(self, color_type: str = IconColors.PRIMARY) -> ColorHex: + """ + Get icon color for the current theme. + + Parameters + ---------- + color_type : str, optional + The type of icon color to retrieve, by default IconColors.PRIMARY. + Must be one of the IconColors constants. + + Returns + ------- + ColorHex + Hex color string (e.g., '#ffffff') appropriate for the current theme. + + Example + -------- + >>> color = theme_manager.get_icon_color(IconColors.PRIMARY) + >>> warning_color = theme_manager.get_icon_color(IconColors.WARNING) + """ + colors = self.dark_icon_colors if self.current_theme == Theme.DARK else self.light_icon_colors + return colors.get(color_type, colors[IconColors.PRIMARY]) + + def create_icon( + self, + icon_name: str, + color_type: str = IconColors.PRIMARY, + scale_factor: float = 1.0, + custom_color: ColorHex | None = None + ) -> QIcon | None: + """ + Create a themed icon using qtawesome. + + Parameters + ---------- + icon_name : str + The qtawesome icon name (e.g., 'fa.home', 'mdi.gear'). + color_type : str, optional + The type of icon color to use, by default IconColors.PRIMARY. + scale_factor : float, optional + Scale factor for icon size, by default 1.0. + custom_color : ColorHex | None, optional + Custom hex color to override theme color, by default None. + + Returns + ------- + QIcon | None + The created icon, or None if qtawesome is not available. + + Example + -------- + >>> icon = theme_manager.create_icon('fa.home') + >>> warning_icon = theme_manager.create_icon('fa.exclamation-triangle', IconColors.WARNING) + >>> custom_icon = theme_manager.create_icon('fa.gear', custom_color='#ff0000') + """ + color = custom_color or self.get_icon_color(color_type) + return qta.icon(icon_name, color=color, scale_factor=scale_factor) + + def get_all_icon_colors(self) -> IconColorDict: + """ + Get all available icon colors for the current theme. + + Returns + ------- + IconColorDict + Dictionary mapping color type names to hex color strings. + + Example + -------- + >>> colors = theme_manager.get_all_icon_colors() + >>> primary_color = colors[IconColors.PRIMARY] + """ + return self.dark_icon_colors.copy() if self.current_theme == Theme.DARK else self.light_icon_colors.copy() + + def set_stylesheet_paths(self, light_path: str | None = None, dark_path: str | None = None) -> None: + """ + Update the stylesheet paths and reapply current theme. + + Parameters + ---------- + light_path : str | None, optional + Path to the light theme QSS file, by default None. + dark_path : str | None, optional + Path to the dark theme QSS file, by default None. + + Examples + -------- + >>> theme_manager.set_stylesheet_paths( + ... light_path="new_styles/light.qss", + ... dark_path="new_styles/dark.qss" + ... ) + """ + if light_path is not None: + self.light_stylesheet_path = light_path + if dark_path is not None: + self.dark_stylesheet_path = dark_path + + # Reapply current theme to load new stylesheets + current = self.current_theme + self.set_theme(current) + + def get_stylesheet_paths(self) -> tuple[str | None, str | None]: + """ + Get the current stylesheet paths. + + Returns + ------- + tuple[str | None, str | None] + Tuple of (light_stylesheet_path, dark_stylesheet_path). + + Examples + -------- + >>> light_path, dark_path = theme_manager.get_stylesheet_paths() + """ + return (self.light_stylesheet_path, self.dark_stylesheet_path) \ No newline at end of file diff --git a/trace/toggle.py b/trace/toggle.py new file mode 100644 index 00000000..1082a93e --- /dev/null +++ b/trace/toggle.py @@ -0,0 +1,189 @@ +from typing import Any, Optional + +from qtpy.QtGui import QColor, QPainter +from qtpy.QtCore import Qt, QRect, Property, QPropertyAnimation +from qtpy.QtWidgets import QCheckBox + + +class ToggleSwitch(QCheckBox): + """ + A custom toggle switch widget that looks like a modern mobile switch. + This widget extends QCheckBox to create a toggle switch with animated + transition between on and off states. The switch consists of a rounded + rectangle track and a circular knob that moves horizontally. + Parameters + ---------- + parent : QWidget, optional + The parent widget. + Attributes + ---------- + TRACK_OFF : QColor + Color of the track when the switch is off. + TRACK_ON : QColor + Color of the track when the switch is on. + DIAMETER : int + Diameter of the circular knob in pixels. + MARGIN : int + Margin between the knob and the track edge in pixels. + """ + + TRACK_OFF = QColor("#454545") + TRACK_ON = QColor("#3a76d8") + DIAMETER = 22 + MARGIN = 2 + + def __init__(self, text: str = "", parent: Optional[Any] = None, color: Optional[QColor] = None) -> None: + """ + Initialize the toggle switch widget. + Parameters + ---------- + text : str, optional + The text label (for compatibility with QCheckBox, but not displayed) + parent : QWidget, optional + The parent widget. + color : QColor, optional + Custom color for the "on" state. If None, uses default blue. + """ + # Handle the case where first argument might be parent widget + if isinstance(text, (type(None), object)) and not isinstance(text, str): + parent = text + text = "" + super().__init__(parent) + self.setFixedSize(46, 26) + self.setCursor(Qt.PointingHandCursor) + self._x = self.MARGIN # Start with knob on the left (off position) + self._anim = QPropertyAnimation(self, b"offset", self) + self._anim.setDuration(120) + + # Set custom color or use default + self._track_on_color = color if color is not None else self.TRACK_ON + + # Don't connect to stateChanged to avoid dual animations + # Animation will only be triggered by user clicks in nextCheckState + + def getOffset(self) -> int: + """ + Get the current horizontal offset of the knob. + Returns + ------- + int + The current x-coordinate of the knob. + """ + return self._x + + def setOffset(self, x: int) -> None: + """ + Set the horizontal offset of the knob and update the widget. + Parameters + ---------- + x : int + The new x-coordinate for the knob. + """ + self._x = x + self.update() + + offset = Property(int, fget=getOffset, fset=setOffset) + + def nextCheckState(self) -> None: + """ + Handle the toggle state change and animate the knob movement. + This method is called when the checkbox state changes and + manages the animation of the knob from one position to another. + """ + super().nextCheckState() + start = self._x + end = self.width() - self.DIAMETER - self.MARGIN if self.isChecked() else self.MARGIN + + self._anim.stop() + self._anim.setStartValue(start) + self._anim.setEndValue(end) + self._anim.start() + + def setChecked(self, checked: bool) -> None: + """ + Override setChecked to handle programmatic state changes with animation. + """ + if self.isChecked() != checked: + super().setChecked(checked) + # Animate to new position + start = self._x + end = self.width() - self.DIAMETER - self.MARGIN if checked else self.MARGIN + self._anim.stop() + self._anim.setStartValue(start) + self._anim.setEndValue(end) + self._anim.start() + + def setCheckState(self, state) -> None: + """ + Override setCheckState to handle Qt.CheckState enums properly in PySide6. + """ + # Convert Qt.CheckState to boolean for setChecked + if isinstance(state, int): + # Handle integer values + checked = state != 0 # 0 = Unchecked, anything else = Checked + else: + # Handle Qt.CheckState enums + checked = state != Qt.Unchecked + + self.setChecked(checked) + + def setColor(self, color: QColor) -> None: + """ + Set the color for the "on" state of the toggle switch. + + Parameters + ---------- + color : QColor + The color to use when the toggle is in the "on" state + """ + self._track_on_color = color + self.update() # Trigger a repaint + + def getColor(self) -> QColor: + """ + Get the current "on" state color. + + Returns + ------- + QColor + The current color used for the "on" state + """ + return self._track_on_color + + def paintEvent(self, _: Any) -> None: + """ + Paint the toggle switch with the appropriate colors and position. + Parameters + ---------- + _ : QPaintEvent + The paint event (unused). + """ + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + + # Draw the track - use custom color for "on" state + track_col = self._track_on_color if self.isChecked() else self.TRACK_OFF + p.setPen(Qt.NoPen) + p.setBrush(track_col) + p.drawRoundedRect(self.rect(), self.height() / 2, self.height() / 2) + + # Draw the knob + knob_rect = QRect(self._x, self.MARGIN, self.DIAMETER, self.DIAMETER) + p.setBrush(Qt.white) + p.drawEllipse(knob_rect) + + def hitButton(self, pos: Any) -> bool: + """ + Determine if the given position is on the button. + This is overridden to make the entire widget clickable, not just + the standard checkbox indicator area. + Parameters + ---------- + pos : QPoint + The position to test. + Returns + ------- + bool + True if the position is within the widget's area, False otherwise. + """ + return self.contentsRect().contains(pos) \ No newline at end of file diff --git a/trace/widgets/control_panel.py b/trace/widgets/control_panel.py index 93787285..f6df3425 100644 --- a/trace/widgets/control_panel.py +++ b/trace/widgets/control_panel.py @@ -2,7 +2,9 @@ import qtawesome as qta from qtpy import QtGui, QtCore, QtWidgets +from toggle import ToggleSwitch from qtpy.QtCore import QTimer +from theme_manager import Theme, IconColors, ThemeManager from pydm.widgets.baseplot import BasePlotAxisItem from pydm.widgets.archiver_time_plot import FormulaCurveItem, ArchivePlotCurveItem @@ -24,24 +26,28 @@ class ControlPanel(QtWidgets.QWidget): curve_list_changed = QtCore.Signal() - def __init__(self): + def __init__(self, theme_manager: ThemeManager = None): super().__init__() + self.theme_manager = theme_manager self.setLayout(QtWidgets.QVBoxLayout()) - self.setStyleSheet("background-color: white;") + # self.setStyleSheet("background-color: white;") self._curve_dict = {} self._next_pv_number = 1 self._next_formula_number = 1 + if self.theme_manager: + self.theme_manager.theme_changed.connect(self.on_theme_changed) + # Create pv plotter layout pv_plotter_layout = QtWidgets.QHBoxLayout() self.layout().addLayout(pv_plotter_layout) - self.search_button = QtWidgets.QPushButton("Search PV") + self.search_button = QtWidgets.QPushButton() + self.search_button.setFlat(True) self.search_button.clicked.connect(self.search_pv) pv_plotter_layout.addWidget(self.search_button) self.calc_button = QtWidgets.QPushButton() - self.calc_button.setIcon(qta.icon("fa6s.calculator")) self.calc_button.setFlat(True) self.calc_button.clicked.connect(self.show_formula_dialog) pv_plotter_layout.addWidget(self.calc_button) @@ -73,6 +79,22 @@ def __init__(self): self.formula_dialog.formula_accepted.connect(self.handle_formula_accepted) self.curve_list_changed.connect(self.formula_dialog.curve_model.refresh) + self.update_icons() + + def update_icons(self): + """Update all icons based on current theme""" + if self.theme_manager: + calc_icon = self.theme_manager.create_icon("fa6s.calculator", IconColors.PRIMARY) + if calc_icon: + self.calc_button.setIcon(calc_icon) + search_icon = self.theme_manager.create_icon("fa6s.magnifying-glass", IconColors.PRIMARY) + if search_icon: + self.search_button.setIcon(search_icon) + + def on_theme_changed(self, theme: Theme): + """Handle theme changes by updating icons""" + self.update_icons() + def minimumSizeHint(self) -> QtCore.QSize: inner_size = self.axis_list.minimumSize() buffer = self.pv_line_edit.font().pointSize() * 3 @@ -176,7 +198,7 @@ def add_empty_axis(self, name: str = "") -> "AxisItem": def add_axis_item(self, axis: BasePlotAxisItem) -> "AxisItem": """Add an existing AxisItem to the plot.""" self.match_axis_tick_font(axis) - axis_item = AxisItem(axis, control_panel=self) + axis_item = AxisItem(axis, control_panel=self, theme_manager=self.theme_manager) axis_item.curves_list_changed.connect(self.curve_list_changed.emit) self.axis_list.insertWidget(self.axis_list.count() - 1, axis_item) logger.debug(f"Added axis {axis.name} to plot") @@ -304,19 +326,22 @@ def closeEvent(self, a0: QtGui.QCloseEvent): class AxisItem(QtWidgets.QWidget): curves_list_changed = QtCore.Signal() - def __init__(self, plot_axis_item: BasePlotAxisItem, control_panel=None): + def __init__(self, plot_axis_item: BasePlotAxisItem, control_panel=None, theme_manager: ThemeManager = None): super().__init__() self.source = plot_axis_item self.control_panel_ref = control_panel + self.theme_manager = theme_manager self.setLayout(QtWidgets.QVBoxLayout()) self.setAcceptDrops(True) + if self.theme_manager: + self.theme_manager.theme_changed.connect(self.on_theme_changed) + self.header_layout = QtWidgets.QHBoxLayout() self.layout().addLayout(self.header_layout) self._expanded = False self.expand_button = QtWidgets.QPushButton() - self.expand_button.setIcon(qta.icon("msc.chevron-right")) self.expand_button.setFlat(True) self.expand_button.clicked.connect(self.toggle_expand) self.header_layout.addWidget(self.expand_button) @@ -331,13 +356,11 @@ def __init__(self, plot_axis_item: BasePlotAxisItem, control_panel=None): self.axis_label.returnPressed.connect(self.axis_label.clearFocus) self.top_settings_layout.addWidget(self.axis_label) self.settings_button = QtWidgets.QPushButton() - self.settings_button.setIcon(qta.icon("msc.settings-gear")) self.settings_button.setFlat(True) self.settings_modal = None self.settings_button.clicked.connect(self.show_settings_modal) self.top_settings_layout.addWidget(self.settings_button) self.delete_button = QtWidgets.QPushButton() - self.delete_button.setIcon(qta.icon("msc.trash")) self.delete_button.setFlat(True) self.delete_button.clicked.connect(self.close) self.top_settings_layout.addWidget(self.delete_button) @@ -362,7 +385,7 @@ def __init__(self, plot_axis_item: BasePlotAxisItem, control_panel=None): self.bottom_settings_layout.addWidget(self.max_range_line_edit) self.source.sigYRangeChanged.connect(self.handle_range_change) - self.active_toggle = QtWidgets.QCheckBox("Active") + self.active_toggle = ToggleSwitch("Active") self.active_toggle.setCheckState(QtCore.Qt.Checked if self.source.isVisible() else QtCore.Qt.Unchecked) self.active_toggle.stateChanged.connect(self.set_active) self.header_layout.addWidget(self.active_toggle) @@ -371,6 +394,31 @@ def __init__(self, plot_axis_item: BasePlotAxisItem, control_panel=None): self.placeholder.hide() self.placeholder.setStyleSheet("background-color: lightgrey;") + self.update_icons() + + def update_icons(self): + """Update all icons based on current theme""" + if self.theme_manager: + if self._expanded: + expand_icon = self.theme_manager.create_icon("msc.chevron-down", IconColors.PRIMARY) + else: + expand_icon = self.theme_manager.create_icon("msc.chevron-right", IconColors.PRIMARY) + + if expand_icon: + self.expand_button.setIcon(expand_icon) + + settings_icon = self.theme_manager.create_icon("msc.settings-gear", IconColors.PRIMARY) + if settings_icon: + self.settings_button.setIcon(settings_icon) + + delete_icon = self.theme_manager.create_icon("msc.trash", IconColors.PRIMARY) + if delete_icon: + self.delete_button.setIcon(delete_icon) + + def on_theme_changed(self, theme: Theme): + """Handle theme changes by updating icons""" + self.update_icons() + @property def plot(self): return self.parent().parent().parent().parent().plot @@ -412,7 +460,7 @@ def add_curve(self, pv: str, channel_args: dict = None) -> "CurveItem": variable_name = control_panel._generate_pv_key("pv") control_panel._curve_dict[variable_name] = plot_curve_item - curve_item = CurveItem(plot_curve_item, variable_name=variable_name) + curve_item = CurveItem(plot_curve_item, variable_name=variable_name, theme_manager=self.theme_manager) curve_item.curve_deleted.connect(self.curves_list_changed.emit) curve_item.curve_deleted.connect(lambda curve: self.handle_curve_deleted(curve)) self.layout().addWidget(curve_item) @@ -463,7 +511,7 @@ def add_formula_curve(self, formula): variable_name = control_panel._generate_pv_key("formula") control_panel._curve_dict[variable_name] = formula_curve_item - curve_item = CurveItem(formula_curve_item, variable_name=variable_name) + curve_item = CurveItem(formula_curve_item, variable_name=variable_name, theme_manager=self.theme_manager) curve_item.curve_deleted.connect(self.curves_list_changed.emit) curve_item.curve_deleted.connect(lambda curve: self.handle_curve_deleted(curve)) curve_item.active_toggle.setCheckState(self.active_toggle.checkState()) @@ -490,11 +538,9 @@ def toggle_expand(self): if self._expanded: for index in range(1, self.layout().count()): self.layout().itemAt(index).widget().hide() - self.expand_button.setIcon(qta.icon("msc.chevron-right")) else: for index in range(1, self.layout().count()): self.layout().itemAt(index).widget().show() - self.expand_button.setIcon(qta.icon("msc.chevron-down")) self._expanded = not self._expanded def set_active(self, state: QtCore.Qt.CheckState): @@ -669,24 +715,30 @@ def mousePressEvent(self, event: QtGui.QMouseEvent): class CurveItem(QtWidgets.QWidget): curve_deleted = QtCore.Signal(object) + # icon_disconnected = qta.icon("msc.debug-disconnect") - icon_disconnected = qta.icon("msc.debug-disconnect") - - def __init__(self, plot_curve_item: ArchivePlotCurveItem, variable_name: str = None) -> None: + def __init__( + self, plot_curve_item: ArchivePlotCurveItem, variable_name: str = None, theme_manager: ThemeManager = None + ) -> None: super().__init__() self.source = plot_curve_item self.is_formula = self._is_formula_curve() self._variable_name = variable_name + self.theme_manager = theme_manager self.setLayout(QtWidgets.QHBoxLayout()) + + self.icon_disconnected = self.theme_manager.create_icon("msc.debug-disconnect", IconColors.PRIMARY) + if self.theme_manager: + self.theme_manager.theme_changed.connect(self.on_theme_changed) + self.handle = DragHandle() self.handle.setFlat(True) - self.handle.setIcon(qta.icon("ph.dots-six-vertical", scale_factor=1.5)) self.handle.setStyleSheet("border: None;") self.handle.setCursor(QtGui.QCursor(QtCore.Qt.OpenHandCursor)) self.layout().addWidget(self.handle) - self.active_toggle = QtWidgets.QCheckBox("Active") + self.active_toggle = ToggleSwitch("Active", color=self.source.color_string) self.active_toggle.setCheckState(QtCore.Qt.Checked if self.source.isVisible() else QtCore.Qt.Unchecked) self.active_toggle.stateChanged.connect(self.set_active) self.layout().addWidget(self.active_toggle) @@ -698,12 +750,6 @@ def __init__(self, plot_curve_item: ArchivePlotCurveItem, variable_name: str = N data_type_layout = QtWidgets.QHBoxLayout() second_layout.addLayout(data_type_layout) - self.color_circle_label = QtWidgets.QLabel() - circle_pixmap = self.create_color_circle(self.source.color_string, 12) - self.color_circle_label.setPixmap(circle_pixmap) - self.color_circle_label.setFixedSize(12, 12) - pv_settings_layout.addWidget(self.color_circle_label) - self.invalid_action = None self.variable_name_label = QtWidgets.QLabel() self.variable_name_label.setMinimumWidth(40) @@ -719,7 +765,6 @@ def __init__(self, plot_curve_item: ArchivePlotCurveItem, variable_name: str = N self.label.returnPressed.connect(self.label.clearFocus) pv_settings_layout.addWidget(self.label) self.pv_settings_button = QtWidgets.QPushButton() - self.pv_settings_button.setIcon(qta.icon("msc.settings-gear")) self.pv_settings_button.setFlat(True) self.pv_settings_modal = None self.pv_settings_button.clicked.connect(self.show_settings_modal) @@ -740,27 +785,39 @@ def __init__(self, plot_curve_item: ArchivePlotCurveItem, variable_name: str = N pv_settings_layout.addWidget(self.archive_connection_status) self.delete_button = QtWidgets.QPushButton() - self.delete_button.setIcon(qta.icon("msc.trash")) self.delete_button.setFlat(True) self.delete_button.clicked.connect(self.close) pv_settings_layout.addWidget(self.delete_button) data_type_layout.addStretch() - def create_color_circle(self, color, size=12): - """Create a colored circle pixmap for the curve color indicator""" - pixmap = QtGui.QPixmap(size, size) - pixmap.fill(QtCore.Qt.transparent) - - painter = QtGui.QPainter(pixmap) - painter.setRenderHint(QtGui.QPainter.Antialiasing) - - painter.setBrush(QtGui.QBrush(QtGui.QColor(color))) - painter.setPen(QtGui.QPen(QtCore.Qt.black, 1)) - painter.drawEllipse(0, 0, size-1, size-1) - painter.end() - - return pixmap + self.update_icons() + + def on_theme_changed(self, theme: Theme): + """Handle theme changes by updating icons""" + self.update_icons() + + def update_icons(self): + """Update all icons based on current theme""" + if self.theme_manager: + self.icon_disconnected = self.theme_manager.create_icon("msc.debug-disconnect", IconColors.PRIMARY) + + handle_icon = self.theme_manager.create_icon("ph.dots-six-vertical", IconColors.PRIMARY, scale_factor=1.5) + if handle_icon: + self.handle.setIcon(handle_icon) + + settings_icon = self.theme_manager.create_icon("msc.settings-gear", IconColors.PRIMARY) + if settings_icon: + self.pv_settings_button.setIcon(settings_icon) + + delete_icon = self.theme_manager.create_icon("msc.trash", IconColors.PRIMARY) + if delete_icon: + self.delete_button.setIcon(delete_icon) + + if self.icon_disconnected: + self.live_connection_status.setPixmap(self.icon_disconnected.pixmap(16, 16)) + self.archive_connection_status.setPixmap(self.icon_disconnected.pixmap(16, 16)) + def update_variable_name(self): """Update the variable name label""" @@ -784,9 +841,8 @@ def show_invalid_icon(self, show=True): border: 2px solid #d32f2f; border-radius: 4px; padding: 4px; - background-color: white; } - """ + """ ) else: if self.invalid_action is not None: @@ -829,14 +885,13 @@ def show_settings_modal(self): @QtCore.Slot(object) def on_color_changed(self, color): """Handle color change from settings modal""" - self.update_color_circle() - - def update_color_circle(self): - """Update the color circle when the curve color changes""" - if hasattr(self, 'color_circle_label'): - curve_color = getattr(self.source, 'color_string', None) - circle_pixmap = self.create_color_circle(curve_color, 12) - self.color_circle_label.setPixmap(circle_pixmap) + self.update_color_toggle() + + def update_color_toggle(self): + """Update the color toggle when the curve color changes""" + if hasattr(self, "active_toggle"): + curve_color = getattr(self.source, "color_string", None) + self.active_toggle.setColor(curve_color) def mousePressEvent(self, event: QtGui.QMouseEvent): if event.button() == QtCore.Qt.LeftButton and self.handle.geometry().contains(event.position().toPoint()): diff --git a/trace/widgets/frozen_table_view.py b/trace/widgets/frozen_table_view.py index 5c772355..b314db86 100644 --- a/trace/widgets/frozen_table_view.py +++ b/trace/widgets/frozen_table_view.py @@ -1,5 +1,5 @@ -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QTableView, QHeaderView +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QTableView, QHeaderView, QAbstractItemView class FrozenTableView(QTableView): @@ -42,9 +42,9 @@ def init(self): self.frozenTableView.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.frozenTableView.show() self.updateFrozenTableGeometry() - self.setHorizontalScrollMode(self.ScrollPerPixel) - self.setVerticalScrollMode(self.ScrollPerPixel) - self.frozenTableView.setVerticalScrollMode(self.ScrollPerPixel) + self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.frozenTableView.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) def updateSectionWidth(self, logicalIndex, oldSize, newSize): if logicalIndex == 0: From 04ae698e2d84fb5e1a25a5959105f149ceaa76d9 Mon Sep 17 00:00:00 2001 From: Yekta Yazar Date: Fri, 29 Aug 2025 10:48:16 -0700 Subject: [PATCH 3/8] fixed an pyside6 issue with depricated stateChanged. --- trace/main.py | 12 +- trace/stylesheets/dark_mode.qss | 4 +- trace/stylesheets/light_mode.qss | 230 ++++++++++++++++++++++++------- trace/theme_manager.py | 179 ++++++++++++------------ trace/toggle.py | 30 ++-- trace/widgets/axis_settings.py | 4 +- trace/widgets/control_panel.py | 21 ++- trace/widgets/curve_settings.py | 2 +- trace/widgets/plot_settings.py | 8 +- 9 files changed, 315 insertions(+), 175 deletions(-) diff --git a/trace/main.py b/trace/main.py index b5a3c003..bef25747 100644 --- a/trace/main.py +++ b/trace/main.py @@ -5,7 +5,7 @@ from getpass import getuser from datetime import datetime -from qtpy.QtGui import QFont, QImage, QKeySequence, QColor +from qtpy.QtGui import QFont, QColor, QImage, QKeySequence from qtpy.QtCore import Qt, Slot, QSize, Signal, QBuffer, QIODevice, QSettings from theme_manager import Theme, IconColors, ThemeManager from qtpy.QtWidgets import ( @@ -118,8 +118,8 @@ def build_plot_side(self, parent): toolbar = self.build_toolbar(plot_side_widget) plot_side_layout.addWidget(toolbar) - background_color = "#1E1E1E" if self.theme_manager.get_current_theme() == Theme.DARK else "white" - + background_color = "#1E1E1E" if self.theme_manager.get_current_theme() == Theme.DARK else "white" + self.plot = PyDMArchiverTimePlot( plot_side_widget, background=background_color, @@ -328,14 +328,14 @@ def construct_trace_menu(self, parent: QMenuBar) -> QMenu: fetch_archive.setShortcut(QKeySequence("Ctrl+F")) dit_action = menu.addAction("Data Insight Tool...", self.data_insight_tool.show) dit_action.setShortcut(QKeySequence("Ctrl+D")) - + menu.addSeparator() if self.is_dark_mode: self.theme_action = menu.addAction("Switch to Light Mode", self.toggle_theme) else: self.theme_action = menu.addAction("Switch to Dark Mode", self.toggle_theme) - + self.theme_action.setShortcut(QKeySequence("Ctrl+T")) return menu @@ -345,7 +345,7 @@ def toggle_theme(self): if self.is_dark_mode: self.theme_manager.set_theme(Theme.LIGHT) self.theme_action.setText("Switch to Dark Mode") - self.plot.setBackgroundColor(QColor("#FFFFFF")) + self.plot.setBackgroundColor(QColor("#FFFFFF")) self.setup_icons() self.is_dark_mode = False else: diff --git a/trace/stylesheets/dark_mode.qss b/trace/stylesheets/dark_mode.qss index 65e02db4..a7c13afd 100644 --- a/trace/stylesheets/dark_mode.qss +++ b/trace/stylesheets/dark_mode.qss @@ -86,7 +86,7 @@ QPushButton:disabled, PyDMPushButton:disabled { cursor: not-allowed; } -/* Icon Buttons */ +/* Flat Icon Buttons */ QPushButton[flat="true"] { background: transparent; border: none; @@ -424,7 +424,7 @@ QComboBox::down-arrow { QComboBox QAbstractItemView { background-color: #2A2A2A; color: #E0E0E0; -/* border: 1px solid #5A5A5A;*/ + /* border: 1px solid #5A5A5A;*/ selection-background-color: #4A4A4A; selection-color: #FFFFFF; } diff --git a/trace/stylesheets/light_mode.qss b/trace/stylesheets/light_mode.qss index a0a1274c..2848f9be 100644 --- a/trace/stylesheets/light_mode.qss +++ b/trace/stylesheets/light_mode.qss @@ -73,6 +73,7 @@ QPushButton:hover, PyDMPushButton:hover { QPushButton:pressed, PyDMPushButton:pressed { background-color: #CFCFCF; + color: #000000; border: 1px solid #999999; padding-top: 7px; padding-bottom: 5px; @@ -82,6 +83,7 @@ QPushButton:disabled, PyDMPushButton:disabled { background-color: #F0F0F0; color: #AAAAAA; border: 1px solid #DDDDDD; + cursor: not-allowed; } /* Flat Icon Buttons */ @@ -90,6 +92,10 @@ QPushButton[flat="true"] { border: none; } +QPushButton[flat="true"]::icon { + color: #202020; +} + QPushButton[flat="true"]:hover { background-color: #EEEEEE; } @@ -98,6 +104,35 @@ QPushButton[flat="true"]:pressed { background-color: #DDDDDD; } +QPushButton QIcon { + color: #202020; +} + +/* Time Range Buttons */ +QPushButton[timeRange=true] { + background-color: #F4F4F4; + border: 1px solid #888888; +} + +QPushButton[timeRange=true]:checked { + background-color: #555555; + color: #FFFFFF; +} + +/* Plot Button */ +QPushButton#plotButton { + background-color: #444444; + border: 1px solid #666666; +} + +QPushButton#plotButton:hover { + background-color: #555555; +} + +QPushButton#plotButton:pressed { + background-color: #333333; +} + /* Line Edits */ QLineEdit, PyDMLineEdit { background-color: #FFFFFF; @@ -113,7 +148,33 @@ QLineEdit:disabled, PyDMLineEdit:disabled { border: 1px solid #DDDDDD; } -/* Labels */ +/* Group Boxes */ +QGroupBox { + border: 1px solid #5A5A5A; + border-radius: 5px; + margin-top: 6px; +} + +QGroupBox::title { + subcontrol-origin: margin; + left: 8px; + padding: 0 4px; +} + +/* Collapsible Sections */ +QWidget[collapsible=true] { + border: 1px solid #5A5A5A; + margin: 6px; + padding: 4px; +} + +QLabel[sectionHeader=true] { + font-weight: bold; + font-size: 11px; + color: #E0E0E0; +} + +/* PV Labels */ PyDMLabel[pvName=true] { color: #007ACC; } @@ -121,7 +182,10 @@ PyDMLabel[pvName=true] { /* Checkboxes */ QCheckBox { spacing: 6px; - color: red; +} + +QCheckBox::indicator:unchecked { + background-color: #2A2A2A; } QCheckBox::indicator { @@ -135,7 +199,6 @@ QCheckBox::indicator { QCheckBox::indicator:checked { background-color: #D0D0D0; image: url("assets/light_icons/check.svg"); - color: red; } QCheckBox::indicator:hover { @@ -143,33 +206,12 @@ QCheckBox::indicator:hover { background-color: #EEEEEE; } -/* Radio Buttons */ -QRadioButton { - spacing: 6px; - color: #202020; -} - -QRadioButton::indicator { - width: 16px; - height: 16px; - border: 1px solid #AAAAAA; - background-color: #FFFFFF; - border-radius: 8px; -} - -QRadioButton::indicator:checked { - background-color: #C8DAF0; - image: url("assets/light_icons/circle-solid.svg"); -} - -QRadioButton::indicator:hover { - border-color: #888888; -} - /* Sliders */ QSlider::handle:horizontal { background: #777777; border: 1px solid #555555; + /*width: 14px; + height: 14px;*/ margin: -4px 0; border-radius: 7px; } @@ -188,32 +230,61 @@ QSlider::add-page:horizontal { border-radius: 3px; } -/* ComboBox */ -QComboBox { - background-color: #FFFFFF; - border: 1px solid #CCCCCC; - border-radius: 4px; - padding: 4px; - color: #202020; +/* Toggle Switches */ +QSwitch, PyDMSwitch { + background-color: #2F2F2F; + border: 1px solid #666666; } -QComboBox::drop-down { - background-color: #FFFFFF; - border-left: 1px solid #CCCCCC; - margin: 0px; +/* PyDMChannel Indicators */ +PyDMChannel[connected=true] { + background-color: #006600; } -QComboBox::down-arrow { - image: url("assets/light_icons/down-arrow.svg"); - width: 10px; - height: 10px; +PyDMChannel[connected=false] { + background-color: #660000; } -QComboBox QAbstractItemView { - background-color: #FFFFFF; - color: #202020; - selection-background-color: #DADADA; - selection-color: #000000; +/* Live/Archive Indicators */ +QLabel[status="live"] { + color: #00CC00; + font-weight: bold; +} + +QLabel[status="archive"] { + color: #FF9900; + font-weight: bold; +} + +/* Plot Areas */ +PyDMTimePlot, PyDMWaveformPlot { + background-color: #1E1E1E; + border: 1px solid #555555; +} + +/* Plot Settings Panel */ +QFrame#plotSettings { + background-color: #2A2A2A; + border: 1px solid #555555; +} + +QFrame#plotSettings QLabel { + color: #E0E0E0; +} + +/* Status Bar */ +QStatusBar { + background-color: #2C2C2C; + border-top: 1px solid #444444; +} + +/* Tooltips */ +QToolTip { + background-color: #333333; + color: #FFFFFF; + border: 1px solid #888888; + padding: 4px; + font-size: 10px; } /* Scrollbars */ @@ -303,6 +374,61 @@ QScrollBar::add-line:horizontal { image: url("assets/light_icons/right-arrow.svg"); } +/* Radio Buttons */ +QRadioButton { + spacing: 6px; +} + +QRadioButton::indicator { + width: 16px; + height: 16px; + border: 1px solid #AAAAAA; + background-color: #FFFFFF; + border-radius: 8px; +} + +QRadioButton::indicator:checked { + background-color: #C8DAF0; + image: url("assets/light_icons/circle-solid.svg"); +} + +QRadioButton::indicator:unchecked { + background-color: #2A2A2A; +} + +QRadioButton::indicator:hover { + border-color: #888888; +} + +/* ComboBox */ +QComboBox { + background-color: #FFFFFF; + border: 1px solid #CCCCCC; + border-radius: 4px; + padding: 4px; + color: #202020; +} + +QComboBox::drop-down { + background-color: #FFFFFF; + border-left: 1px solid #CCCCCC; + margin: 0px; +} + +QComboBox::down-arrow { + image: url("assets/light_icons/down-arrow.svg"); + width: 10px; + height: 10px; +} + +QComboBox QAbstractItemView { + background-color: #FFFFFF; + color: #202020; + /* border: 1px solid #5A5A5A;*/ + selection-background-color: #DADADA; + selection-color: #000000; +} + /* SpinBox */ QSpinBox { background-color: #FFFFFF; @@ -317,10 +443,21 @@ QSpinBox::up-button, QSpinBox::down-button { width: 16px; } +QSpinBox::up-button { + subcontrol-position: top right; + border-top-right-radius: 4px; +} + +QSpinBox::down-button { + subcontrol-position: bottom right; + border-bottom-right-radius: 4px; +} + QSpinBox::up-arrow { image: url("assets/light_icons/up-arrow.svg"); width: 8px; height: 8px; + color: #5EA7FF; } QSpinBox::down-arrow { @@ -328,4 +465,3 @@ QSpinBox::down-arrow { width: 8px; height: 8px; } - diff --git a/trace/theme_manager.py b/trace/theme_manager.py index 74198b25..df6bb2e7 100644 --- a/trace/theme_manager.py +++ b/trace/theme_manager.py @@ -1,11 +1,11 @@ from __future__ import annotations -from enum import Enum -from qtpy.QtWidgets import QApplication, QPushButton, QStyleFactory -from qtpy.QtCore import QObject, Signal, QSettings -from qtpy.QtGui import QPalette, QColor, QIcon +from enum import Enum import qtawesome as qta +from qtpy.QtGui import QIcon, QColor, QPalette +from qtpy.QtCore import Signal, QObject, QSettings +from qtpy.QtWidgets import QPushButton, QApplication, QStyleFactory type ColorHex = str type IconColorDict = dict[str, ColorHex] @@ -14,28 +14,30 @@ class Theme(Enum): """Theme enumeration for light and dark modes.""" + LIGHT = "light" DARK = "dark" class IconColors: """Constants for icon color types.""" - PRIMARY: str = 'primary' - SECONDARY: str = 'secondary' - ACCENT: str = 'accent' - SUCCESS: str = 'success' - WARNING: str = 'warning' - ERROR: str = 'error' - DISABLED: str = 'disabled' + + PRIMARY: str = "primary" + SECONDARY: str = "secondary" + ACCENT: str = "accent" + SUCCESS: str = "success" + WARNING: str = "warning" + ERROR: str = "error" + DISABLED: str = "disabled" class ThemeManager(QObject): """ theme manager for Qt applications with icon support. - + Manages both Qt palette themes and icon colors, providing a unified interface for light/dark mode switching with persistent settings. - + Attributes ---------- theme_changed : Signal @@ -53,14 +55,19 @@ class ThemeManager(QObject): dark_icon_colors : IconColorDict Icon color mapping for dark theme. """ - + theme_changed = Signal(Theme) - - def __init__(self, app: QApplication, parent: QObject | None = None, light_stylesheet_path: str | None = None, dark_stylesheet_path: str | None = None -) -> None: + + def __init__( + self, + app: QApplication, + parent: QObject | None = None, + light_stylesheet_path: str | None = None, + dark_stylesheet_path: str | None = None, + ) -> None: """ Initialize the integrated theme manager. - + Parameters ---------- app : QApplication @@ -71,7 +78,7 @@ def __init__(self, app: QApplication, parent: QObject | None = None, light_style Path to the light theme QSS stylesheet file, by default None. dark_stylesheet_path : str | None, optional Path to the dark theme QSS stylesheet file, by default None. - + Example -------- >>> app = QApplication(sys.argv) @@ -81,24 +88,24 @@ def __init__(self, app: QApplication, parent: QObject | None = None, light_style super().__init__(parent) self.app = app self.current_theme = Theme.LIGHT - + self.light_stylesheet_path = light_stylesheet_path self.dark_stylesheet_path = dark_stylesheet_path self.app.setStyle(QStyleFactory.create("Fusion")) - + self._setup_palettes() self._setup_icon_colors() - + # Load saved theme preference settings = QSettings() is_dark = settings.value("isDarkTheme", False, bool) self.set_theme(Theme.DARK if is_dark else Theme.LIGHT) - + def _setup_palettes(self) -> None: """ Setup Qt palettes for light and dark themes. - + Creates and configures QPalette objects with appropriate colors for both light and dark themes, including disabled state colors. """ @@ -117,7 +124,7 @@ def _setup_palettes(self) -> None: self.light_palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218)) self.light_palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218)) self.light_palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255)) - + # Dark palette self.dark_palette = QPalette() self.dark_palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53)) @@ -133,68 +140,68 @@ def _setup_palettes(self) -> None: self.dark_palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218)) self.dark_palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218)) self.dark_palette.setColor(QPalette.ColorRole.HighlightedText, QColor(0, 0, 0)) - + # Disabled colors for both palettes disabled_light_color = QColor(120, 120, 120) self.light_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled_light_color) self.light_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_light_color) self.light_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_light_color) - + disabled_dark_color = QColor(120, 120, 120) self.dark_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled_dark_color) self.dark_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_dark_color) self.dark_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_dark_color) - + def _setup_icon_colors(self) -> None: """ Setup icon color schemes for light and dark themes. - + Defines color mappings for different icon types (primary, secondary, etc.) optimized for visibility and accessibility in both light and dark themes. """ self.light_icon_colors: IconColorDict = { - IconColors.PRIMARY: '#000000', # Black for primary icons - IconColors.SECONDARY: '#666666', # Dark gray for secondary icons - IconColors.ACCENT: '#0078d4', # Blue for accent colors - IconColors.SUCCESS: '#107c10', # Green for success - IconColors.WARNING: '#ff8c00', # Orange for warnings - IconColors.ERROR: '#d13438', # Red for errors - IconColors.DISABLED: '#999999', # Light gray for disabled + IconColors.PRIMARY: "#000000", # Black for primary icons + IconColors.SECONDARY: "#666666", # Dark gray for secondary icons + IconColors.ACCENT: "#0078d4", # Blue for accent colors + IconColors.SUCCESS: "#107c10", # Green for success + IconColors.WARNING: "#ff8c00", # Orange for warnings + IconColors.ERROR: "#d13438", # Red for errors + IconColors.DISABLED: "#999999", # Light gray for disabled } - + self.dark_icon_colors: IconColorDict = { - IconColors.PRIMARY: '#ffffff', # White for primary icons - IconColors.SECONDARY: '#cccccc', # Light gray for secondary icons - IconColors.ACCENT: '#0078d4', # Blue for accent colors - IconColors.SUCCESS: '#107c10', # Green for success - IconColors.WARNING: '#ff8c00', # Orange for warnings - IconColors.ERROR: '#d13438', # Red for errors - IconColors.DISABLED: '#666666', # Dark gray for disabled + IconColors.PRIMARY: "#ffffff", # White for primary icons + IconColors.SECONDARY: "#cccccc", # Light gray for secondary icons + IconColors.ACCENT: "#0078d4", # Blue for accent colors + IconColors.SUCCESS: "#107c10", # Green for success + IconColors.WARNING: "#ff8c00", # Orange for warnings + IconColors.ERROR: "#d13438", # Red for errors + IconColors.DISABLED: "#666666", # Dark gray for disabled } - + def _load_stylesheet(self, stylesheet_path: str | None) -> str: """ Load a QSS stylesheet from file. - + Parameters ---------- stylesheet_path : str | None Path to the QSS stylesheet file, or None to return empty string. - + Returns ------- str The stylesheet content, or empty string if file cannot be loaded. - + Examples -------- >>> stylesheet = theme_manager._load_stylesheet("styles/dark.qss") """ if not stylesheet_path: return "" - + try: - with open(stylesheet_path, 'r', encoding='utf-8') as file: + with open(stylesheet_path, "r", encoding="utf-8") as file: return file.read() except FileNotFoundError: print(f"Warning: Stylesheet file not found: {stylesheet_path}") @@ -203,7 +210,7 @@ def _load_stylesheet(self, stylesheet_path: str | None) -> str: print(f"Warning: Could not decode stylesheet file: {stylesheet_path}") try: # Try with different encoding - with open(stylesheet_path, 'r', encoding='latin-1') as file: + with open(stylesheet_path, "r", encoding="latin-1") as file: return file.read() except Exception as e: print(f"Warning: Failed to load stylesheet {stylesheet_path}: {e}") @@ -215,73 +222,73 @@ def _load_stylesheet(self, stylesheet_path: str | None) -> str: def set_theme(self, theme: Theme) -> None: """ Set the application theme. - + Parameters ---------- theme : Theme The theme to apply (Theme.LIGHT or Theme.DARK). - + Examples -------- >>> theme_manager.set_theme(Theme.DARK) >>> theme_manager.set_theme(Theme.LIGHT) """ self.current_theme = theme - + if theme == Theme.DARK: self.app.setPalette(self.dark_palette) stylesheet = self._load_stylesheet(self.dark_stylesheet_path) else: self.app.setPalette(self.light_palette) stylesheet = self._load_stylesheet(self.light_stylesheet_path) - + self.app.setStyleSheet(stylesheet) - + settings = QSettings() settings.setValue("isDarkTheme", theme == Theme.DARK) - + self.theme_changed.emit(theme) - + def toggle_theme(self) -> None: """ Toggle between light and dark themes. - + Switches from light to dark or dark to light, whichever is opposite to the current theme. - + Example -------- >>> theme_manager.toggle_theme() # Switches to opposite theme """ new_theme = Theme.DARK if self.current_theme == Theme.LIGHT else Theme.LIGHT self.set_theme(new_theme) - + def get_current_theme(self) -> Theme: """ Get the current theme. - + Returns ------- Theme The currently active theme. """ return self.current_theme - + def get_icon_color(self, color_type: str = IconColors.PRIMARY) -> ColorHex: """ Get icon color for the current theme. - + Parameters ---------- color_type : str, optional The type of icon color to retrieve, by default IconColors.PRIMARY. Must be one of the IconColors constants. - + Returns ------- ColorHex Hex color string (e.g., '#ffffff') appropriate for the current theme. - + Example -------- >>> color = theme_manager.get_icon_color(IconColors.PRIMARY) @@ -289,17 +296,17 @@ def get_icon_color(self, color_type: str = IconColors.PRIMARY) -> ColorHex: """ colors = self.dark_icon_colors if self.current_theme == Theme.DARK else self.light_icon_colors return colors.get(color_type, colors[IconColors.PRIMARY]) - + def create_icon( - self, - icon_name: str, - color_type: str = IconColors.PRIMARY, - scale_factor: float = 1.0, - custom_color: ColorHex | None = None + self, + icon_name: str, + color_type: str = IconColors.PRIMARY, + scale_factor: float = 1.0, + custom_color: ColorHex | None = None, ) -> QIcon | None: """ Create a themed icon using qtawesome. - + Parameters ---------- icon_name : str @@ -310,12 +317,12 @@ def create_icon( Scale factor for icon size, by default 1.0. custom_color : ColorHex | None, optional Custom hex color to override theme color, by default None. - + Returns ------- QIcon | None The created icon, or None if qtawesome is not available. - + Example -------- >>> icon = theme_manager.create_icon('fa.home') @@ -324,34 +331,34 @@ def create_icon( """ color = custom_color or self.get_icon_color(color_type) return qta.icon(icon_name, color=color, scale_factor=scale_factor) - + def get_all_icon_colors(self) -> IconColorDict: """ Get all available icon colors for the current theme. - + Returns ------- IconColorDict Dictionary mapping color type names to hex color strings. - + Example -------- >>> colors = theme_manager.get_all_icon_colors() >>> primary_color = colors[IconColors.PRIMARY] """ return self.dark_icon_colors.copy() if self.current_theme == Theme.DARK else self.light_icon_colors.copy() - + def set_stylesheet_paths(self, light_path: str | None = None, dark_path: str | None = None) -> None: """ Update the stylesheet paths and reapply current theme. - + Parameters ---------- light_path : str | None, optional Path to the light theme QSS file, by default None. dark_path : str | None, optional Path to the dark theme QSS file, by default None. - + Examples -------- >>> theme_manager.set_stylesheet_paths( @@ -363,22 +370,22 @@ def set_stylesheet_paths(self, light_path: str | None = None, dark_path: str | N self.light_stylesheet_path = light_path if dark_path is not None: self.dark_stylesheet_path = dark_path - + # Reapply current theme to load new stylesheets current = self.current_theme self.set_theme(current) - + def get_stylesheet_paths(self) -> tuple[str | None, str | None]: """ Get the current stylesheet paths. - + Returns ------- tuple[str | None, str | None] Tuple of (light_stylesheet_path, dark_stylesheet_path). - + Examples -------- >>> light_path, dark_path = theme_manager.get_stylesheet_paths() """ - return (self.light_stylesheet_path, self.dark_stylesheet_path) \ No newline at end of file + return (self.light_stylesheet_path, self.dark_stylesheet_path) diff --git a/trace/toggle.py b/trace/toggle.py index 1082a93e..213ebc20 100644 --- a/trace/toggle.py +++ b/trace/toggle.py @@ -44,22 +44,17 @@ def __init__(self, text: str = "", parent: Optional[Any] = None, color: Optional color : QColor, optional Custom color for the "on" state. If None, uses default blue. """ - # Handle the case where first argument might be parent widget if isinstance(text, (type(None), object)) and not isinstance(text, str): parent = text text = "" super().__init__(parent) self.setFixedSize(46, 26) self.setCursor(Qt.PointingHandCursor) - self._x = self.MARGIN # Start with knob on the left (off position) + self._x = self.MARGIN self._anim = QPropertyAnimation(self, b"offset", self) self._anim.setDuration(120) - - # Set custom color or use default - self._track_on_color = color if color is not None else self.TRACK_ON - # Don't connect to stateChanged to avoid dual animations - # Animation will only be triggered by user clicks in nextCheckState + self._track_on_color = color if color is not None else self.TRACK_ON def getOffset(self) -> int: """ @@ -93,7 +88,7 @@ def nextCheckState(self) -> None: super().nextCheckState() start = self._x end = self.width() - self.DIAMETER - self.MARGIN if self.isChecked() else self.MARGIN - + self._anim.stop() self._anim.setStartValue(start) self._anim.setEndValue(end) @@ -105,7 +100,6 @@ def setChecked(self, checked: bool) -> None: """ if self.isChecked() != checked: super().setChecked(checked) - # Animate to new position start = self._x end = self.width() - self.DIAMETER - self.MARGIN if checked else self.MARGIN self._anim.stop() @@ -117,32 +111,29 @@ def setCheckState(self, state) -> None: """ Override setCheckState to handle Qt.CheckState enums properly in PySide6. """ - # Convert Qt.CheckState to boolean for setChecked if isinstance(state, int): - # Handle integer values - checked = state != 0 # 0 = Unchecked, anything else = Checked + checked = state != 0 else: - # Handle Qt.CheckState enums checked = state != Qt.Unchecked - + self.setChecked(checked) def setColor(self, color: QColor) -> None: """ Set the color for the "on" state of the toggle switch. - + Parameters ---------- color : QColor The color to use when the toggle is in the "on" state """ self._track_on_color = color - self.update() # Trigger a repaint + self.update() def getColor(self) -> QColor: """ Get the current "on" state color. - + Returns ------- QColor @@ -161,10 +152,9 @@ def paintEvent(self, _: Any) -> None: p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) - # Draw the track - use custom color for "on" state track_col = self._track_on_color if self.isChecked() else self.TRACK_OFF p.setPen(Qt.NoPen) - p.setBrush(track_col) + p.setBrush(QColor(track_col)) p.drawRoundedRect(self.rect(), self.height() / 2, self.height() / 2) # Draw the knob @@ -186,4 +176,4 @@ def hitButton(self, pos: Any) -> bool: bool True if the position is within the widget's area, False otherwise. """ - return self.contentsRect().contains(pos) \ No newline at end of file + return self.contentsRect().contains(pos) diff --git a/trace/widgets/axis_settings.py b/trace/widgets/axis_settings.py index 899d6b05..036b417b 100644 --- a/trace/widgets/axis_settings.py +++ b/trace/widgets/axis_settings.py @@ -31,13 +31,13 @@ def __init__(self, parent: QWidget, plot: PyDMArchiverTimePlot, axis: BasePlotAx log_checkbox = QCheckBox(self) log_checkbox.setChecked(self.axis.log_mode) - log_checkbox.stateChanged.connect(self.set_axis_log_mode) + log_checkbox.checkStateChanged.connect(self.set_axis_log_mode) log_mode_row = SettingsRowItem(self, "Log Mode", log_checkbox) main_layout.addLayout(log_mode_row) self.grid_checkbox = QCheckBox(self) self.grid_checkbox.setChecked(bool(self.axis.grid)) - self.grid_checkbox.stateChanged.connect(self.show_grid) + self.grid_checkbox.checkStateChanged.connect(self.show_grid) y_grid_row = SettingsRowItem(self, "Y Axis Gridline", self.grid_checkbox) main_layout.addLayout(y_grid_row) diff --git a/trace/widgets/control_panel.py b/trace/widgets/control_panel.py index f6df3425..1a6b8e10 100644 --- a/trace/widgets/control_panel.py +++ b/trace/widgets/control_panel.py @@ -368,7 +368,7 @@ def __init__(self, plot_axis_item: BasePlotAxisItem, control_panel=None, theme_m layout.addLayout(self.bottom_settings_layout) self.auto_range_checkbox = QtWidgets.QCheckBox("Auto") self.auto_range_checkbox.setCheckState(QtCore.Qt.Checked if self.source.auto_range else QtCore.Qt.Unchecked) - self.auto_range_checkbox.stateChanged.connect(self.set_auto_range) + self.auto_range_checkbox.checkStateChanged.connect(self.set_auto_range) self.source.linkedView().sigRangeChangedManually.connect(self.disable_auto_range) self.bottom_settings_layout.addWidget(self.auto_range_checkbox) self.bottom_settings_layout.addWidget(QtWidgets.QLabel("min, max")) @@ -387,7 +387,7 @@ def __init__(self, plot_axis_item: BasePlotAxisItem, control_panel=None, theme_m self.active_toggle = ToggleSwitch("Active") self.active_toggle.setCheckState(QtCore.Qt.Checked if self.source.isVisible() else QtCore.Qt.Unchecked) - self.active_toggle.stateChanged.connect(self.set_active) + self.active_toggle.checkStateChanged.connect(self.set_active) self.header_layout.addWidget(self.active_toggle) self.placeholder = QtWidgets.QWidget(self) @@ -726,12 +726,12 @@ def __init__( self._variable_name = variable_name self.theme_manager = theme_manager self.setLayout(QtWidgets.QHBoxLayout()) - + self.icon_disconnected = self.theme_manager.create_icon("msc.debug-disconnect", IconColors.PRIMARY) if self.theme_manager: self.theme_manager.theme_changed.connect(self.on_theme_changed) - + self.handle = DragHandle() self.handle.setFlat(True) self.handle.setStyleSheet("border: None;") @@ -740,7 +740,7 @@ def __init__( self.active_toggle = ToggleSwitch("Active", color=self.source.color_string) self.active_toggle.setCheckState(QtCore.Qt.Checked if self.source.isVisible() else QtCore.Qt.Unchecked) - self.active_toggle.stateChanged.connect(self.set_active) + self.active_toggle.checkStateChanged.connect(self.set_active) self.layout().addWidget(self.active_toggle) second_layout = QtWidgets.QVBoxLayout() @@ -772,12 +772,20 @@ def __init__( self.setup_line_edit() + self.live_toggle = QtWidgets.QCheckBox("Live") + self.live_toggle.setCheckState(QtCore.Qt.Checked if self.source.liveData else QtCore.Qt.Unchecked) + self.live_toggle.checkStateChanged.connect(self.set_live_data_connection) + data_type_layout.addWidget(self.live_toggle) self.live_connection_status = QtWidgets.QLabel() self.live_connection_status.setPixmap(self.icon_disconnected.pixmap(16, 16)) self.live_connection_status.setToolTip("Not connected to live data") self.source.live_channel_connection.connect(self.update_live_icon) pv_settings_layout.addWidget(self.live_connection_status) + self.archive_toggle = QtWidgets.QCheckBox("Archive") + self.archive_toggle.setCheckState(QtCore.Qt.Checked if self.source.use_archive_data else QtCore.Qt.Unchecked) + self.archive_toggle.checkStateChanged.connect(self.set_archive_data_connection) + data_type_layout.addWidget(self.archive_toggle) self.archive_connection_status = QtWidgets.QLabel() self.archive_connection_status.setPixmap(self.icon_disconnected.pixmap(16, 16)) self.archive_connection_status.setToolTip("Not connected to archive data") @@ -813,12 +821,11 @@ def update_icons(self): delete_icon = self.theme_manager.create_icon("msc.trash", IconColors.PRIMARY) if delete_icon: self.delete_button.setIcon(delete_icon) - + if self.icon_disconnected: self.live_connection_status.setPixmap(self.icon_disconnected.pixmap(16, 16)) self.archive_connection_status.setPixmap(self.icon_disconnected.pixmap(16, 16)) - def update_variable_name(self): """Update the variable name label""" if self._variable_name: diff --git a/trace/widgets/curve_settings.py b/trace/widgets/curve_settings.py index b3613dc0..c6bbd10b 100644 --- a/trace/widgets/curve_settings.py +++ b/trace/widgets/curve_settings.py @@ -76,7 +76,7 @@ def __init__(self, parent: QWidget, plot: PyDMArchiverTimePlot, curve: TimePlotC main_layout.addLayout(width_row) extention_option = QCheckBox(self) - extention_option.stateChanged.connect(lambda check: self.set_extension_option(bool(check))) + extention_option.checkStateChanged.connect(lambda check: self.set_extension_option(bool(check))) extention_option_row = SettingsRowItem(self, "Line Extention", extention_option) main_layout.addLayout(extention_option_row) diff --git a/trace/widgets/plot_settings.py b/trace/widgets/plot_settings.py index 1b0c588c..0d4b86b4 100644 --- a/trace/widgets/plot_settings.py +++ b/trace/widgets/plot_settings.py @@ -46,7 +46,7 @@ def __init__(self, parent: QWidget, plot: PyDMArchiverTimePlot): main_layout.addLayout(plot_title_row) self.legend_checkbox = QCheckBox(self) - self.legend_checkbox.stateChanged.connect(lambda check: self.plot.setShowLegend(bool(check))) + self.legend_checkbox.checkStateChanged.connect(lambda check: self.plot.setShowLegend(bool(check))) self.legend_checkbox.setChecked(True) # legend on by default legend_row = SettingsRowItem(self, "Show Legend", self.legend_checkbox) main_layout.addLayout(legend_row) @@ -81,7 +81,7 @@ def __init__(self, parent: QWidget, plot: PyDMArchiverTimePlot): main_layout.addLayout(end_dt_row) self.crosshair_checkbox = QCheckBox(self) - self.crosshair_checkbox.stateChanged.connect(lambda check: self.plot.enableCrosshair(check, 100, 100)) + self.crosshair_checkbox.checkStateChanged.connect(lambda check: self.plot.enableCrosshair(check, 100, 100)) crosshair_row = SettingsRowItem(self, "Show Crosshair", self.crosshair_checkbox) main_layout.addLayout(crosshair_row) @@ -101,12 +101,12 @@ def __init__(self, parent: QWidget, plot: PyDMArchiverTimePlot): main_layout.addLayout(axis_tick_font_size_row) self.x_grid_checkbox = QCheckBox(self) - self.x_grid_checkbox.stateChanged.connect(self.show_x_grid) + self.x_grid_checkbox.checkStateChanged.connect(self.show_x_grid) x_grid_row = SettingsRowItem(self, " X Axis Gridline", self.x_grid_checkbox) main_layout.addLayout(x_grid_row) self.y_grid_checkbox = QCheckBox(self) - self.y_grid_checkbox.stateChanged.connect(self.show_y_grid) + self.y_grid_checkbox.checkStateChanged.connect(self.show_y_grid) y_grid_row = SettingsRowItem(self, " All Y Axis Gridlines", self.y_grid_checkbox) main_layout.addLayout(y_grid_row) From f2eba50e635be67fc1032407cf12e9a91ee41cc0 Mon Sep 17 00:00:00 2001 From: Yekta Yazar Date: Fri, 29 Aug 2025 14:17:06 -0700 Subject: [PATCH 4/8] addressed another issue caused by pyside6 --- trace/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/trace/main.py b/trace/main.py index bef25747..83ec887c 100644 --- a/trace/main.py +++ b/trace/main.py @@ -24,6 +24,7 @@ QVBoxLayout, QApplication, QButtonGroup, + QAbstractButton, ) from pyqtgraph.exporters import ImageExporter from services.elog_client import get_user, post_entry @@ -462,7 +463,8 @@ def set_plot_timerange(self, timerange: tuple[float, float]) -> None: @Slot() @Slot(float) - def set_auto_scroll_span(self, timespan: float = None) -> None: + @Slot(QAbstractButton, bool) + def set_auto_scroll_span(self, _=None, timespan: float = None) -> None: """Slot to be called when a timespan setting button is pressed. This will enable autoscrolling along the x-axis and disable mouse controls. If the "Cursor" button is pressed, then autoscrolling is From 128dec3cfa63bd64ee7304bef3efbdd3ad9cab12 Mon Sep 17 00:00:00 2001 From: Yekta Yazar Date: Fri, 29 Aug 2025 14:29:59 -0700 Subject: [PATCH 5/8] fixed rebase mistake --- trace/main.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/trace/main.py b/trace/main.py index 83ec887c..75141a6a 100644 --- a/trace/main.py +++ b/trace/main.py @@ -252,12 +252,6 @@ def set_file_indicator(self, file_path: str) -> None: self.file_label.setToolTip("Currently loaded file") self.footer_info_widget.layout().addWidget(self.file_label) - def configure_app(self): - """UI changes to be made to the PyDMApplication""" - app = QApplication.instance() - if not app.main_window: - return - def setup_icons(self): """Set up all icons after theme manager is initialized""" self.settings_button.setIcon(self.theme_manager.create_icon("msc.settings-gear", IconColors.PRIMARY)) From 83347cb30a3a82fd31478f8ea141e2c96e3e93ce Mon Sep 17 00:00:00 2001 From: Yekta Yazar Date: Fri, 29 Aug 2025 14:51:17 -0700 Subject: [PATCH 6/8] a few more rebase fixes --- trace/main.py | 23 ++++++++++++++--------- trace/widgets/control_panel.py | 8 -------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/trace/main.py b/trace/main.py index 75141a6a..9bd408de 100644 --- a/trace/main.py +++ b/trace/main.py @@ -457,24 +457,29 @@ def set_plot_timerange(self, timerange: tuple[float, float]) -> None: @Slot() @Slot(float) - @Slot(QAbstractButton, bool) - def set_auto_scroll_span(self, _=None, timespan: float = None) -> None: + @Slot(QAbstractButton, float) + def set_auto_scroll_span(self, arg1=None, arg2=None) -> None: """Slot to be called when a timespan setting button is pressed. This will enable autoscrolling along the x-axis and disable mouse controls. If the "Cursor" button is pressed, then autoscrolling is - disabled and mouse controls are enabled. - """ - if timespan is None: - timespan = self.timespan_buttons.checkedId() - enable_scroll = timespan != DISABLE_AUTO_SCROLL + disabled and mouse controls are enabled.""" + if isinstance(arg1, QAbstractButton): + if not arg2: + return + timespan = self.timespan_buttons.id(arg1) + elif isinstance(arg1, (int, float)): + timespan = arg1 else: - enable_scroll = True - self.disable_auto_scroll_button.click() + timespan = self.timespan_buttons.checkedId() + + enable_scroll = timespan != DISABLE_AUTO_SCROLL if enable_scroll: logger.debug(f"Enabling plot autoscroll for {timespan}s") else: logger.debug("Disabling plot autoscroll, using mouse controls") + self.disable_auto_scroll_button.click() + self.autoScroll(enable=enable_scroll, timespan=timespan) @Slot(int) diff --git a/trace/widgets/control_panel.py b/trace/widgets/control_panel.py index 1a6b8e10..02540667 100644 --- a/trace/widgets/control_panel.py +++ b/trace/widgets/control_panel.py @@ -772,20 +772,12 @@ def __init__( self.setup_line_edit() - self.live_toggle = QtWidgets.QCheckBox("Live") - self.live_toggle.setCheckState(QtCore.Qt.Checked if self.source.liveData else QtCore.Qt.Unchecked) - self.live_toggle.checkStateChanged.connect(self.set_live_data_connection) - data_type_layout.addWidget(self.live_toggle) self.live_connection_status = QtWidgets.QLabel() self.live_connection_status.setPixmap(self.icon_disconnected.pixmap(16, 16)) self.live_connection_status.setToolTip("Not connected to live data") self.source.live_channel_connection.connect(self.update_live_icon) pv_settings_layout.addWidget(self.live_connection_status) - self.archive_toggle = QtWidgets.QCheckBox("Archive") - self.archive_toggle.setCheckState(QtCore.Qt.Checked if self.source.use_archive_data else QtCore.Qt.Unchecked) - self.archive_toggle.checkStateChanged.connect(self.set_archive_data_connection) - data_type_layout.addWidget(self.archive_toggle) self.archive_connection_status = QtWidgets.QLabel() self.archive_connection_status.setPixmap(self.icon_disconnected.pixmap(16, 16)) self.archive_connection_status.setToolTip("Not connected to archive data") From c695117478bd7e93350704b8441e23c1904e96e9 Mon Sep 17 00:00:00 2001 From: Yekta Yazar Date: Thu, 4 Sep 2025 15:44:34 -0700 Subject: [PATCH 7/8] changes recommended during code review --- trace/config.py | 3 + trace/main.py | 4 +- trace/{ => services}/theme_manager.py | 99 ++------------------------- trace/widgets/control_panel.py | 4 +- trace/{ => widgets}/toggle.py | 0 5 files changed, 10 insertions(+), 100 deletions(-) rename trace/{ => services}/theme_manager.py (76%) rename trace/{ => widgets}/toggle.py (100%) diff --git a/trace/config.py b/trace/config.py index 84e6927d..e20b3ec4 100644 --- a/trace/config.py +++ b/trace/config.py @@ -9,6 +9,9 @@ with config_file.open() as f: loaded_json = load(f) +light_stylesheet = Path(__file__).parent / "stylesheets/light_mode.qss" +dark_stylesheet = Path(__file__).parent / "stylesheets/dark_mode.qss" + logger = getLogger("") datetime_pv = loaded_json["datetime_pv"] diff --git a/trace/main.py b/trace/main.py index 9bd408de..a3629116 100644 --- a/trace/main.py +++ b/trace/main.py @@ -7,7 +7,7 @@ from qtpy.QtGui import QFont, QColor, QImage, QKeySequence from qtpy.QtCore import Qt, Slot, QSize, Signal, QBuffer, QIODevice, QSettings -from theme_manager import Theme, IconColors, ThemeManager +from services.theme_manager import Theme, IconColors, ThemeManager from qtpy.QtWidgets import ( QMenu, QLabel, @@ -54,8 +54,6 @@ def __init__(self, parent=None, args=None, macros=None) -> None: self.theme_manager = ThemeManager( app, - light_stylesheet_path="stylesheets/light_mode.qss", - dark_stylesheet_path="stylesheets/dark_mode.qss", ) settings = QSettings() self.is_dark_mode = settings.value("isDarkTheme", False, type=bool) diff --git a/trace/theme_manager.py b/trace/services/theme_manager.py similarity index 76% rename from trace/theme_manager.py rename to trace/services/theme_manager.py index df6bb2e7..883efe82 100644 --- a/trace/theme_manager.py +++ b/trace/services/theme_manager.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import Enum - +from config import logger, light_stylesheet, dark_stylesheet import qtawesome as qta from qtpy.QtGui import QIcon, QColor, QPalette from qtpy.QtCore import Signal, QObject, QSettings @@ -62,8 +62,6 @@ def __init__( self, app: QApplication, parent: QObject | None = None, - light_stylesheet_path: str | None = None, - dark_stylesheet_path: str | None = None, ) -> None: """ Initialize the integrated theme manager. @@ -74,10 +72,6 @@ def __init__( The Qt application instance to manage themes for. parent : QObject | None, optional Parent QObject for memory management, by default None. - light_stylesheet_path : str | None, optional - Path to the light theme QSS stylesheet file, by default None. - dark_stylesheet_path : str | None, optional - Path to the dark theme QSS stylesheet file, by default None. Example -------- @@ -88,10 +82,6 @@ def __init__( super().__init__(parent) self.app = app self.current_theme = Theme.LIGHT - - self.light_stylesheet_path = light_stylesheet_path - self.dark_stylesheet_path = dark_stylesheet_path - self.app.setStyle(QStyleFactory.create("Fusion")) self._setup_palettes() @@ -179,46 +169,6 @@ def _setup_icon_colors(self) -> None: IconColors.DISABLED: "#666666", # Dark gray for disabled } - def _load_stylesheet(self, stylesheet_path: str | None) -> str: - """ - Load a QSS stylesheet from file. - - Parameters - ---------- - stylesheet_path : str | None - Path to the QSS stylesheet file, or None to return empty string. - - Returns - ------- - str - The stylesheet content, or empty string if file cannot be loaded. - - Examples - -------- - >>> stylesheet = theme_manager._load_stylesheet("styles/dark.qss") - """ - if not stylesheet_path: - return "" - - try: - with open(stylesheet_path, "r", encoding="utf-8") as file: - return file.read() - except FileNotFoundError: - print(f"Warning: Stylesheet file not found: {stylesheet_path}") - return "" - except UnicodeDecodeError: - print(f"Warning: Could not decode stylesheet file: {stylesheet_path}") - try: - # Try with different encoding - with open(stylesheet_path, "r", encoding="latin-1") as file: - return file.read() - except Exception as e: - print(f"Warning: Failed to load stylesheet {stylesheet_path}: {e}") - return "" - except Exception as e: - print(f"Warning: Failed to load stylesheet {stylesheet_path}: {e}") - return "" - def set_theme(self, theme: Theme) -> None: """ Set the application theme. @@ -237,12 +187,12 @@ def set_theme(self, theme: Theme) -> None: if theme == Theme.DARK: self.app.setPalette(self.dark_palette) - stylesheet = self._load_stylesheet(self.dark_stylesheet_path) + stylesheet = dark_stylesheet.read_text() else: self.app.setPalette(self.light_palette) - stylesheet = self._load_stylesheet(self.light_stylesheet_path) + stylesheet = light_stylesheet.read_text() - self.app.setStyleSheet(stylesheet) + self.app.main_window.setStyleSheet(stylesheet) settings = QSettings() settings.setValue("isDarkTheme", theme == Theme.DARK) @@ -348,44 +298,3 @@ def get_all_icon_colors(self) -> IconColorDict: """ return self.dark_icon_colors.copy() if self.current_theme == Theme.DARK else self.light_icon_colors.copy() - def set_stylesheet_paths(self, light_path: str | None = None, dark_path: str | None = None) -> None: - """ - Update the stylesheet paths and reapply current theme. - - Parameters - ---------- - light_path : str | None, optional - Path to the light theme QSS file, by default None. - dark_path : str | None, optional - Path to the dark theme QSS file, by default None. - - Examples - -------- - >>> theme_manager.set_stylesheet_paths( - ... light_path="new_styles/light.qss", - ... dark_path="new_styles/dark.qss" - ... ) - """ - if light_path is not None: - self.light_stylesheet_path = light_path - if dark_path is not None: - self.dark_stylesheet_path = dark_path - - # Reapply current theme to load new stylesheets - current = self.current_theme - self.set_theme(current) - - def get_stylesheet_paths(self) -> tuple[str | None, str | None]: - """ - Get the current stylesheet paths. - - Returns - ------- - tuple[str | None, str | None] - Tuple of (light_stylesheet_path, dark_stylesheet_path). - - Examples - -------- - >>> light_path, dark_path = theme_manager.get_stylesheet_paths() - """ - return (self.light_stylesheet_path, self.dark_stylesheet_path) diff --git a/trace/widgets/control_panel.py b/trace/widgets/control_panel.py index 02540667..6eb0555f 100644 --- a/trace/widgets/control_panel.py +++ b/trace/widgets/control_panel.py @@ -2,9 +2,9 @@ import qtawesome as qta from qtpy import QtGui, QtCore, QtWidgets -from toggle import ToggleSwitch +from widgets.toggle import ToggleSwitch from qtpy.QtCore import QTimer -from theme_manager import Theme, IconColors, ThemeManager +from services.theme_manager import Theme, IconColors, ThemeManager from pydm.widgets.baseplot import BasePlotAxisItem from pydm.widgets.archiver_time_plot import FormulaCurveItem, ArchivePlotCurveItem diff --git a/trace/toggle.py b/trace/widgets/toggle.py similarity index 100% rename from trace/toggle.py rename to trace/widgets/toggle.py From c70d0647ec81706ea2b85ced524c9fc8b97a5995 Mon Sep 17 00:00:00 2001 From: Yekta Yazar Date: Thu, 4 Sep 2025 16:20:48 -0700 Subject: [PATCH 8/8] formatting fix --- trace/main.py | 2 +- trace/services/theme_manager.py | 5 +++-- trace/widgets/control_panel.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/trace/main.py b/trace/main.py index a3629116..5f5645ac 100644 --- a/trace/main.py +++ b/trace/main.py @@ -7,7 +7,6 @@ from qtpy.QtGui import QFont, QColor, QImage, QKeySequence from qtpy.QtCore import Qt, Slot, QSize, Signal, QBuffer, QIODevice, QSettings -from services.theme_manager import Theme, IconColors, ThemeManager from qtpy.QtWidgets import ( QMenu, QLabel, @@ -28,6 +27,7 @@ ) from pyqtgraph.exporters import ImageExporter from services.elog_client import get_user, post_entry +from services.theme_manager import Theme, IconColors, ThemeManager from pydm import Display from pydm.widgets import PyDMLabel, PyDMArchiverTimePlot diff --git a/trace/services/theme_manager.py b/trace/services/theme_manager.py index 883efe82..8d352d25 100644 --- a/trace/services/theme_manager.py +++ b/trace/services/theme_manager.py @@ -1,12 +1,14 @@ from __future__ import annotations from enum import Enum -from config import logger, light_stylesheet, dark_stylesheet + import qtawesome as qta from qtpy.QtGui import QIcon, QColor, QPalette from qtpy.QtCore import Signal, QObject, QSettings from qtpy.QtWidgets import QPushButton, QApplication, QStyleFactory +from config import dark_stylesheet, light_stylesheet + type ColorHex = str type IconColorDict = dict[str, ColorHex] type ButtonIconInfo = tuple[str, QPushButton, str, str] @@ -297,4 +299,3 @@ def get_all_icon_colors(self) -> IconColorDict: >>> primary_color = colors[IconColors.PRIMARY] """ return self.dark_icon_colors.copy() if self.current_theme == Theme.DARK else self.light_icon_colors.copy() - diff --git a/trace/widgets/control_panel.py b/trace/widgets/control_panel.py index 6eb0555f..24803963 100644 --- a/trace/widgets/control_panel.py +++ b/trace/widgets/control_panel.py @@ -2,7 +2,6 @@ import qtawesome as qta from qtpy import QtGui, QtCore, QtWidgets -from widgets.toggle import ToggleSwitch from qtpy.QtCore import QTimer from services.theme_manager import Theme, IconColors, ThemeManager @@ -16,6 +15,7 @@ CurveSettingsModal, ArchiveSearchWidget, ) +from widgets.toggle import ToggleSwitch from widgets.formula_dialog import FormulaDialog from widgets.utilities.formula_validation import ( validate_formula,