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/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 6fa8baeb..5f5645ac 100644
--- a/trace/main.py
+++ b/trace/main.py
@@ -5,9 +5,8 @@
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, QColor, QImage, QKeySequence
+from qtpy.QtCore import Qt, Slot, QSize, Signal, QBuffer, QIODevice, QSettings
from qtpy.QtWidgets import (
QMenu,
QLabel,
@@ -24,9 +23,11 @@
QVBoxLayout,
QApplication,
QButtonGroup,
+ QAbstractButton,
)
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
@@ -46,8 +47,19 @@ 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,
+ )
+ 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 +89,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 +101,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 +117,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 +136,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)
@@ -246,12 +250,28 @@ 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))
+
+ 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)
@@ -302,8 +322,35 @@ def construct_trace_menu(self, parent: QMenuBar) -> QMenu:
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
@@ -408,23 +455,29 @@ 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, 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/services/theme_manager.py b/trace/services/theme_manager.py
new file mode 100644
index 00000000..8d352d25
--- /dev/null
+++ b/trace/services/theme_manager.py
@@ -0,0 +1,301 @@
+from __future__ import annotations
+
+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
+
+from config import dark_stylesheet, light_stylesheet
+
+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,
+ ) -> 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.
+
+ 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.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 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 = dark_stylesheet.read_text()
+ else:
+ self.app.setPalette(self.light_palette)
+ stylesheet = light_stylesheet.read_text()
+
+ self.app.main_window.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()
diff --git a/trace/stylesheets/dark_mode.qss b/trace/stylesheets/dark_mode.qss
new file mode 100644
index 00000000..a7c13afd
--- /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;
+}
+
+/* Flat 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..2848f9be
--- /dev/null
+++ b/trace/stylesheets/light_mode.qss
@@ -0,0 +1,467 @@
+/* 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;
+ color: #000000;
+ border: 1px solid #999999;
+ padding-top: 7px;
+ padding-bottom: 5px;
+}
+
+QPushButton:disabled, PyDMPushButton:disabled {
+ background-color: #F0F0F0;
+ color: #AAAAAA;
+ border: 1px solid #DDDDDD;
+ cursor: not-allowed;
+}
+
+/* Flat Icon Buttons */
+QPushButton[flat="true"] {
+ background: transparent;
+ border: none;
+}
+
+QPushButton[flat="true"]::icon {
+ color: #202020;
+}
+
+QPushButton[flat="true"]:hover {
+ background-color: #EEEEEE;
+}
+
+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;
+ 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;
+}
+
+/* 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;
+}
+
+/* Checkboxes */
+QCheckBox {
+ spacing: 6px;
+}
+
+QCheckBox::indicator:unchecked {
+ background-color: #2A2A2A;
+}
+
+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");
+}
+
+QCheckBox::indicator:hover {
+ border: 1px solid #AAAAAA;
+ background-color: #EEEEEE;
+}
+
+/* Sliders */
+QSlider::handle:horizontal {
+ background: #777777;
+ border: 1px solid #555555;
+ /*width: 14px;
+ height: 14px;*/
+ 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;
+}
+
+/* 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: #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");
+}
+
+/* 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;
+ 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-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 {
+ image: url("assets/light_icons/down-arrow.svg");
+ width: 8px;
+ height: 8px;
+}
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 8370f3fd..24803963 100644
--- a/trace/widgets/control_panel.py
+++ b/trace/widgets/control_panel.py
@@ -3,6 +3,7 @@
import qtawesome as qta
from qtpy import QtGui, QtCore, QtWidgets
from qtpy.QtCore import QTimer
+from services.theme_manager import Theme, IconColors, ThemeManager
from pydm.widgets.baseplot import BasePlotAxisItem
from pydm.widgets.archiver_time_plot import FormulaCurveItem, ArchivePlotCurveItem
@@ -14,6 +15,7 @@
CurveSettingsModal,
ArchiveSearchWidget,
)
+from widgets.toggle import ToggleSwitch
from widgets.formula_dialog import FormulaDialog
from widgets.utilities.formula_validation import (
validate_formula,
@@ -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)
@@ -345,7 +368,7 @@ def __init__(self, plot_axis_item: BasePlotAxisItem, control_panel=None):
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"))
@@ -362,15 +385,40 @@ 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.active_toggle.checkStateChanged.connect(self.set_active)
self.header_layout.addWidget(self.active_toggle)
self.placeholder = QtWidgets.QWidget(self)
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,26 +715,32 @@ 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.active_toggle.checkStateChanged.connect(self.set_active)
self.layout().addWidget(self.active_toggle)
second_layout = QtWidgets.QVBoxLayout()
@@ -713,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)
@@ -734,13 +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()
+ 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"""
if self._variable_name:
@@ -763,9 +840,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:
@@ -802,8 +878,20 @@ 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_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()):
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..c6bbd10b 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)
@@ -74,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)
@@ -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:
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:
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)
diff --git a/trace/widgets/toggle.py b/trace/widgets/toggle.py
new file mode 100644
index 00000000..213ebc20
--- /dev/null
+++ b/trace/widgets/toggle.py
@@ -0,0 +1,179 @@
+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.
+ """
+ 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
+ self._anim = QPropertyAnimation(self, b"offset", self)
+ self._anim.setDuration(120)
+
+ self._track_on_color = color if color is not None else self.TRACK_ON
+
+ 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)
+ 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.
+ """
+ if isinstance(state, int):
+ checked = state != 0
+ else:
+ 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()
+
+ 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)
+
+ track_col = self._track_on_color if self.isChecked() else self.TRACK_OFF
+ p.setPen(Qt.NoPen)
+ p.setBrush(QColor(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)