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)