From b4cde4e05759e86bd0f33200ad615ee652dd2577 Mon Sep 17 00:00:00 2001 From: David Meyer Date: Mon, 24 Nov 2025 16:42:41 -0500 Subject: [PATCH 01/11] Remove qtutils star imports to aid in PySide6 support and other future refactors. --- blacs/__main__.py | 16 +++++++++++----- blacs/analysis_submission.py | 7 +++---- blacs/compile_and_restart.py | 7 +++---- blacs/device_base_class.py | 14 +++++++++++--- blacs/experiment_queue.py | 12 ++++++++---- blacs/front_panel_settings.py | 6 ++---- blacs/output_classes.py | 12 +++++++++--- blacs/plugins/connection_table/__init__.py | 8 ++++---- blacs/plugins/theme/__init__.py | 2 +- blacs/tab_base_classes.py | 8 ++++---- 10 files changed, 56 insertions(+), 36 deletions(-) diff --git a/blacs/__main__.py b/blacs/__main__.py index 56aedb99..0c8473d6 100644 --- a/blacs/__main__.py +++ b/blacs/__main__.py @@ -36,11 +36,17 @@ # No splash update for Qt - the splash code has already imported it: import qtutils -from qtutils import * +from qtutils import inmain_decorator, inmain_later, inmain, inthread, UiLoader import qtutils.icons -from qtutils.qt.QtCore import * -from qtutils.qt.QtGui import * -from qtutils.qt.QtWidgets import * +from qtutils.qt.QtCore import PYQT_VERSION_STR, QT_VERSION_STR, QTimer, Qt +from qtutils.qt.QtGui import QIcon +from qtutils.qt.QtWidgets import ( + QMainWindow, + QToolButton, + QMessageBox, + QFileDialog, + QApplication +) from qtutils.qt import QT_ENV @@ -166,7 +172,7 @@ def on_click(self): # Ensure they can't run the game twice at once: self.setEnabled(False) # Wait for the subprocess in a thread so that we know when it quits: - qtutils.inthread(self.run_measure_ball) + inthread(self.run_measure_ball) def run_measure_ball(self): try: diff --git a/blacs/analysis_submission.py b/blacs/analysis_submission.py index 40aa3325..6339ff3a 100644 --- a/blacs/analysis_submission.py +++ b/blacs/analysis_submission.py @@ -17,11 +17,10 @@ import sys import queue -from qtutils.qt.QtCore import * -from qtutils.qt.QtGui import * -from qtutils.qt.QtWidgets import * +from qtutils.qt.QtCore import Qt, QSize +from qtutils.qt.QtGui import QIcon -from qtutils import * +from qtutils import inmain_decorator, UiLoader from zprocess import TimeoutError, raise_exception_in_thread from zprocess.security import AuthenticationFailure from labscript_utils.ls_zprocess import zmq_get diff --git a/blacs/compile_and_restart.py b/blacs/compile_and_restart.py index ea867dca..8e40ac0b 100644 --- a/blacs/compile_and_restart.py +++ b/blacs/compile_and_restart.py @@ -13,11 +13,10 @@ import os import shutil -from qtutils.qt.QtCore import * -from qtutils.qt.QtGui import * -from qtutils.qt.QtWidgets import * +from qtutils.qt.QtCore import Qt, QTimer +from qtutils.qt.QtWidgets import QDialog -from qtutils import * +from qtutils import inmain_decorator, UiLoader import runmanager from labscript_utils.qtwidgets.outputbox import OutputBox diff --git a/blacs/device_base_class.py b/blacs/device_base_class.py index 6cc8ccb8..abaf868a 100644 --- a/blacs/device_base_class.py +++ b/blacs/device_base_class.py @@ -16,9 +16,17 @@ import time from queue import Queue -from qtutils.qt.QtCore import * -from qtutils.qt.QtGui import * -from qtutils.qt.QtWidgets import * +from qtutils.qt.QtCore import QTimer +from qtutils.qt.QtGui import QIcon +from qtutils.qt.QtWidgets import ( + QWidget, + QSpacerItem, + QSizePolicy, + QPushButton, + QHBoxLayout, + QApplication, + QVBoxLayout, +) import labscript_utils.excepthook from qtutils import UiLoader diff --git a/blacs/experiment_queue.py b/blacs/experiment_queue.py index dce7efdd..cb26c480 100644 --- a/blacs/experiment_queue.py +++ b/blacs/experiment_queue.py @@ -22,16 +22,20 @@ from tempfile import gettempdir from binascii import hexlify -from qtutils.qt.QtCore import * -from qtutils.qt.QtGui import * -from qtutils.qt.QtWidgets import * +from qtutils.qt.QtCore import Qt, QItemSelectionModel +from qtutils.qt.QtGui import QIcon, QAction, QStandardItemModel, QStandardItem +from qtutils.qt.QtWidgets import ( + QTreeView, + QMenu, + QFileDialog, +) import zprocess from labscript_utils.ls_zprocess import ProcessTree process_tree = ProcessTree.instance() import labscript_utils.h5_lock, h5py -from qtutils import * +from qtutils import inmain_decorator, inmain from labscript_utils.qtwidgets.elide_label import elide_label from labscript_utils.connections import ConnectionTable diff --git a/blacs/front_panel_settings.py b/blacs/front_panel_settings.py index 25f6a8fb..768b4005 100644 --- a/blacs/front_panel_settings.py +++ b/blacs/front_panel_settings.py @@ -13,14 +13,12 @@ import os import logging -from qtutils.qt.QtCore import * -from qtutils.qt.QtGui import * -from qtutils.qt.QtWidgets import * +from qtutils.qt.QtWidgets import QMessageBox import labscript_utils.excepthook import numpy import labscript_utils.h5_lock, h5py -from qtutils import * +from qtutils import inmain_decorator from labscript_utils.connections import ConnectionTable diff --git a/blacs/output_classes.py b/blacs/output_classes.py index a45cba29..a58f1fd7 100644 --- a/blacs/output_classes.py +++ b/blacs/output_classes.py @@ -14,9 +14,15 @@ import math import sys -from qtutils.qt.QtCore import * -from qtutils.qt.QtGui import * -from qtutils.qt.QtWidgets import * +from qtutils.qt.QtCore import Qt +from qtutils.qt.QtGui import QStandardItemModel, QStandardItem +from qtutils.qt.QtWidgets import ( + QApplication, + QWidget, + QVBoxLayout, + QSpacerItem, + QSizePolicy, +) from labscript_utils.qtwidgets.analogoutput import AnalogOutput diff --git a/blacs/plugins/connection_table/__init__.py b/blacs/plugins/connection_table/__init__.py index 946f97ff..1aa9b15a 100644 --- a/blacs/plugins/connection_table/__init__.py +++ b/blacs/plugins/connection_table/__init__.py @@ -16,13 +16,13 @@ import sys import ast -from qtutils.qt.QtCore import * -from qtutils.qt.QtGui import * -from qtutils.qt.QtWidgets import * +from qtutils.qt.QtCore import Qt +from qtutils.qt.QtGui import QStandardItemModel, QStandardItem +from qtutils.qt.QtWidgets import QMessageBox, QFileDialog from blacs.compile_and_restart import CompileAndRestart from labscript_utils.filewatcher import FileWatcher -from qtutils import * +from qtutils import inmain, UiLoader from blacs.plugins import PLUGINS_DIR FILEPATH_COLUMN = 0 diff --git a/blacs/plugins/theme/__init__.py b/blacs/plugins/theme/__init__.py index 77ba0d90..fa82befc 100644 --- a/blacs/plugins/theme/__init__.py +++ b/blacs/plugins/theme/__init__.py @@ -13,7 +13,7 @@ import logging import os -from qtutils import * +from qtutils import UiLoader from blacs.plugins import PLUGINS_DIR diff --git a/blacs/tab_base_classes.py b/blacs/tab_base_classes.py index 077223de..6d9cbb39 100644 --- a/blacs/tab_base_classes.py +++ b/blacs/tab_base_classes.py @@ -24,11 +24,11 @@ from types import GeneratorType from bisect import insort -from qtutils.qt.QtCore import * -from qtutils.qt.QtGui import * -from qtutils.qt.QtWidgets import * +from qtutils.qt.QtCore import Qt, QTimer +from qtutils.qt.QtGui import QIcon, QColor +from qtutils.qt.QtWidgets import QLabel, QWidget, QPushButton, QApplication, QVBoxLayout -from qtutils import * +from qtutils import inmain_decorator, inmain, inthread, UiLoader from labscript_utils.qtwidgets.outputbox import OutputBox import qtutils.icons From 39ba46206a99c490c1fef5ee1f28a7b7ca5887fa Mon Sep 17 00:00:00 2001 From: David Meyer Date: Mon, 24 Nov 2025 16:47:40 -0500 Subject: [PATCH 02/11] Fix bug in exception handler --- blacs/experiment_queue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blacs/experiment_queue.py b/blacs/experiment_queue.py index cb26c480..35693656 100644 --- a/blacs/experiment_queue.py +++ b/blacs/experiment_queue.py @@ -992,7 +992,7 @@ def restart_function(device_name): message = self.process_request(path) except Exception: # TODO: make this error popup for the user - self._logger.exception('Failed to copy h5_file (%s) for repeat run'%s) + self._logger.exception('Failed to copy h5_file (%s) for repeat run' % path) logger.info(message) self.set_status("Idle") From 91ab6ebc2384450190fd3671f5a6dcaf92a2b8a4 Mon Sep 17 00:00:00 2001 From: David Meyer Date: Mon, 24 Nov 2025 17:14:48 -0500 Subject: [PATCH 03/11] Modernize type checks --- blacs/device_base_class.py | 6 +++--- blacs/front_panel_settings.py | 4 ++-- blacs/tab_base_classes.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/blacs/device_base_class.py b/blacs/device_base_class.py index abaf868a..658fb5b7 100644 --- a/blacs/device_base_class.py +++ b/blacs/device_base_class.py @@ -347,7 +347,7 @@ def auto_place_widgets(self,*args): for arg in args: # A default sort algorithm that just returns the object (this is equivalent to not specifying the sort gorithm) sort_algorithm = lambda x: x - if type(arg) == type(()) and len(arg) > 1 and type(arg[1]) == type({}) and len(arg[1].keys()) > 0: + if isinstance(arg, tuple) and len(arg) > 1 and isinstance(arg[1], dict) and len(arg[1].keys()) > 0: # we have a name, use it! name = arg[0] widget_dict = arg[1] @@ -355,7 +355,7 @@ def auto_place_widgets(self,*args): sort_algorithm = arg[2] else: # ignore things that are not dictionaries or empty dictionaries - if type(arg) != type({}) or len(arg.keys()) < 1: + if not isinstance(arg, dict) or len(arg.keys()) < 1: continue if isinstance(self.get_channel(list(arg.keys())[0]),AO): name = 'Analog Outputs' @@ -480,7 +480,7 @@ def check_remote_values(self): # If no results were returned, raise an exception so that we don't keep calling this function over and over again, # filling up the text box with the same error, eventually consuming all CPU/memory of the PC - if not self._last_remote_values or type(self._last_remote_values) != type({}): + if not self._last_remote_values or not isinstance(self._last_programmed_values, dict): raise Exception('Failed to get remote values from device. Is it still connected?') # A variable to indicate if any of the channels have a changed value diff --git a/blacs/front_panel_settings.py b/blacs/front_panel_settings.py index 768b4005..019eed7a 100644 --- a/blacs/front_panel_settings.py +++ b/blacs/front_panel_settings.py @@ -135,7 +135,7 @@ def handle_return_code(self,row,result,settings,question,error): # This is because we have the original channel, and the moved channel in the same place #-1: Device no longer in the connection table, throw error #-2: Device parameters not compatible, throw error - if type(result) == tuple: + if isinstance(result, tuple): connection = result[1] result = result[0] @@ -283,7 +283,7 @@ def save_front_panel_to_h5(self,current_file,states,tab_positions,window_data,pl if save_conn_table or result: with h5py.File(current_file,'r+') as hdf5_file: - if hdf5_file['/'].get('front_panel') != None: + if hdf5_file['/'].get('front_panel') is not None: # Create a dialog to ask whether we can overwrite! overwrite = False if not silent: diff --git a/blacs/tab_base_classes.py b/blacs/tab_base_classes.py index 6d9cbb39..3fde3617 100644 --- a/blacs/tab_base_classes.py +++ b/blacs/tab_base_classes.py @@ -782,7 +782,7 @@ def mainloop(self): # run the function in the Qt main thread generator = inmain(func,self,*args,**kwargs) # Do any work that was queued up:(we only talk to the worker if work has been queued up through the yield command) - if type(generator) == GeneratorType: + if isinstance(generator, GeneratorType): # We need to call next recursively, queue up work and send the results back until we get a StopIteration exception generator_running = True # get the data from the first yield function From 4bba15cf53f3b21930bce1be5f17189c60e70247 Mon Sep 17 00:00:00 2001 From: David Meyer Date: Tue, 2 Dec 2025 11:37:42 -0500 Subject: [PATCH 04/11] Update dummy tab_base_classes so testing code runs again. --- blacs/tab_base_classes.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/blacs/tab_base_classes.py b/blacs/tab_base_classes.py index 3fde3617..088db4d2 100644 --- a/blacs/tab_base_classes.py +++ b/blacs/tab_base_classes.py @@ -1087,7 +1087,7 @@ def initUI(self): # appearance settings). You should never be calling queue_work # or do_after from un undecorated callback. @define_state(MODE_MANUAL,True) - def foo(self): + def foo(self,button=None): self.logger.debug('entered foo') #self.toplevel.set_sensitive(False) # Here's how you instruct the worker process to do @@ -1113,7 +1113,7 @@ def fatal(self): self.queue_work('My worker','foo', 5,6,7,x='x') @define_state(MODE_MANUAL,True) - def bar(self): + def bar(self,button): self.logger.debug('entered bar') results = yield(self.queue_work('My worker','bar', 5,6,7,x=5)) @@ -1134,7 +1134,7 @@ def baz(self, button=None): # This event shows what happens if you try to send a unpickleable # event through a queue to the subprocess: @define_state(MODE_MANUAL,True) - def baz_unpickleable(self): + def baz_unpickleable(self, button): self.logger.debug('entered baz_unpickleable') results = yield(self.queue_work('My worker','baz', 5,6,7,x=threading.Lock())) self.logger.debug('leaving baz_unpickleable') @@ -1167,7 +1167,10 @@ def init(self): # the former. global serial; import serial self.logger.info('got x! %d' % self.x) - raise Exception('bad import!') + # randomly fail this on init, but only occasionally + import random + if random.random() < 0.15: + raise Exception('bad import!') # Here's a function that will be called when requested by the parent # process. There's nothing special about it really. Its return @@ -1195,6 +1198,8 @@ def baz(self,zzz,*args,**kwargs): import sys import logging.handlers # Setup logging: + from labscript_utils.setup_logging import setup_logging + setup_logging('BLACS') logger = logging.getLogger('BLACS') handler = logging.handlers.RotatingFileHandler(os.path.join(BLACS_DIR, 'BLACS.log'), maxBytes=1024**2, backupCount=0) formatter = logging.Formatter('%(asctime)s %(levelname)s %(name)s: %(message)s') @@ -1222,6 +1227,7 @@ def baz(self,zzz,*args,**kwargs): class FakeConnection(object): def __init__(self): self.BLACS_connection = 'None' + self.properties = {} class FakeConnectionTable(object): def __init__(self): pass From f18503ea3c59d119945c4fde77b588f1cb38e51f Mon Sep 17 00:00:00 2001 From: David Meyer Date: Mon, 24 Nov 2025 17:17:16 -0500 Subject: [PATCH 05/11] Base compatibility with PySide6 --- blacs/__main__.py | 18 ++++++++++-------- blacs/device_base_class.py | 2 +- blacs/front_panel_settings.py | 6 +++--- blacs/output_classes.py | 2 +- blacs/plugins/connection_table/__init__.py | 6 +++--- blacs/tab_base_classes.py | 2 +- 6 files changed, 19 insertions(+), 17 deletions(-) diff --git a/blacs/__main__.py b/blacs/__main__.py index 0c8473d6..0ae35b30 100644 --- a/blacs/__main__.py +++ b/blacs/__main__.py @@ -32,13 +32,14 @@ import time from pathlib import Path import platform +import importlib.metadata WINDOWS = platform.system() == 'Windows' # No splash update for Qt - the splash code has already imported it: import qtutils from qtutils import inmain_decorator, inmain_later, inmain, inthread, UiLoader import qtutils.icons -from qtutils.qt.QtCore import PYQT_VERSION_STR, QT_VERSION_STR, QTimer, Qt +from qtutils.qt.QtCore import qVersion, QTimer, Qt from qtutils.qt.QtGui import QIcon from qtutils.qt.QtWidgets import ( QMainWindow, @@ -48,6 +49,7 @@ QApplication ) from qtutils.qt import QT_ENV +PYQT_VERSION_STR = importlib.metadata.version(QT_ENV) splash.update_text("importing zmq and zprocess") @@ -85,7 +87,7 @@ logger.info(f'h5py version: {h5py.version.info}') logger.info(f'Qt enviroment: {QT_ENV}') logger.info(f'PySide/PyQt version: {PYQT_VERSION_STR}') -logger.info(f'Qt version: {QT_VERSION_STR}') +logger.info(f'Qt version: {qVersion()}') logger.info(f'qtutils version: {qtutils.__version__}') logger.info(f'zprocess version: {zprocess.__version__}') logger.info(f'labscript_utils version: {labscript_utils.__version__}') @@ -538,7 +540,7 @@ def on_load_front_panel(self,*args,**kwargs): dialog = QFileDialog(None,"Select file to load", self.exp_config.get('paths','experiment_shot_storage'), "HDF5 files (*.h5 *.hdf5)") dialog.setViewMode(QFileDialog.Detail) dialog.setFileMode(QFileDialog.ExistingFile) - if dialog.exec_(): + if dialog.exec(): selected_files = dialog.selectedFiles() filepath = str(selected_files[0]) # Qt has this weird behaviour where if you type in the name of a file that exists @@ -557,7 +559,7 @@ def on_load_front_panel(self,*args,**kwargs): message.setWindowTitle("BLACS") message.setStandardButtons(QMessageBox.Yes|QMessageBox.No) - if message.exec_() == QMessageBox.Yes: + if message.exec() == QMessageBox.Yes: front_panel_settings = FrontPanelSettings(filepath, self.connection_table) settings,question,error,tab_data = front_panel_settings.restore() #TODO: handle question/error @@ -589,7 +591,7 @@ def on_load_front_panel(self,*args,**kwargs): message.setText("Unable to load the front panel. The error encountered is printed below.\n\n%s"%str(e)) message.setIcon(QMessageBox.Information) message.setWindowTitle("BLACS") - message.exec_() + message.exec() finally: dialog.deleteLater() else: @@ -598,7 +600,7 @@ def on_load_front_panel(self,*args,**kwargs): message.setText("You did not select a file ending with .h5 or .hdf5. Please try again") message.setIcon(QMessageBox.Information) message.setWindowTitle("BLACS") - message.exec_() + message.exec() QTimer.singleShot(10,self.on_load_front_panel) def on_save_exit(self): @@ -685,7 +687,7 @@ def on_save_front_panel(self,*args,**kwargs): dialog.setFileMode(QFileDialog.AnyFile) dialog.setAcceptMode(QFileDialog.AcceptSave) - if dialog.exec_(): + if dialog.exec(): current_file = str(dialog.selectedFiles()[0]) if not current_file.endswith('.h5'): current_file += '.h5' @@ -781,6 +783,6 @@ def process(self,h5_filepath): splash.hide() def execute_program(): - qapplication.exec_() + qapplication.exec() sys.exit(execute_program()) diff --git a/blacs/device_base_class.py b/blacs/device_base_class.py index 658fb5b7..038a9afe 100644 --- a/blacs/device_base_class.py +++ b/blacs/device_base_class.py @@ -981,6 +981,6 @@ def add_my_tab(self,tab): window.add_my_tab(tab1) window.show() def run(): - app.exec_() + app.exec() sys.exit(run()) diff --git a/blacs/front_panel_settings.py b/blacs/front_panel_settings.py index 019eed7a..ff108224 100644 --- a/blacs/front_panel_settings.py +++ b/blacs/front_panel_settings.py @@ -294,7 +294,7 @@ def save_front_panel_to_h5(self,current_file,states,tab_positions,window_data,pl message.setDefaultButton(QMessageBox.No) message.setIcon(QMessageBox.Question) message.setWindowTitle("BLACS") - resp = message.exec_() + resp = message.exec() if resp == QMessageBox.Yes : overwrite = True @@ -311,7 +311,7 @@ def save_front_panel_to_h5(self,current_file,states,tab_positions,window_data,pl message.setText("Front Panel not saved.") message.setIcon(QMessageBox.Information) message.setWindowTitle("BLACS") - message.exec_() + message.exec() else: logger.info("Front Panel not saved as it already existed in the h5 file '"+current_file+"'") return @@ -325,7 +325,7 @@ def save_front_panel_to_h5(self,current_file,states,tab_positions,window_data,pl message.setText("The Front Panel was not saved as the file selected contains a connection table which is not a subset of the BLACS connection table.") message.setIcon(QMessageBox.Information) message.setWindowTitle("BLACS") - message.exec_() + message.exec() else: logger.info("Front Panel not saved as the connection table in the h5 file '"+current_file+"' didn't match the current connection table.") return diff --git a/blacs/output_classes.py b/blacs/output_classes.py index a58f1fd7..cdced017 100644 --- a/blacs/output_classes.py +++ b/blacs/output_classes.py @@ -1037,4 +1037,4 @@ def print_something(): window.show() - sys.exit(qapplication.exec_()) + sys.exit(qapplication.exec()) diff --git a/blacs/plugins/connection_table/__init__.py b/blacs/plugins/connection_table/__init__.py index 1aa9b15a..649b55da 100644 --- a/blacs/plugins/connection_table/__init__.py +++ b/blacs/plugins/connection_table/__init__.py @@ -393,7 +393,7 @@ def add_global_file(self,*args,**kwargs): dialog.setViewMode(QFileDialog.Detail) dialog.setFileMode(QFileDialog.ExistingFiles) - if dialog.exec_(): + if dialog.exec(): selected_files = dialog.selectedFiles() for filepath in selected_files: filepath = os.path.normpath(filepath) @@ -430,7 +430,7 @@ def add_calibration_file(self): dialog.setViewMode(QFileDialog.Detail) dialog.setFileMode(QFileDialog.ExistingFiles) - if dialog.exec_(): + if dialog.exec(): selected_files = dialog.selectedFiles() for filepath in selected_files: filepath = os.path.normpath(filepath) @@ -453,7 +453,7 @@ def add_calibration_folder(self): dialog.setViewMode(QFileDialog.Detail) dialog.setFileMode(QFileDialog.Directory) - if dialog.exec_(): + if dialog.exec(): selected_files = dialog.selectedFiles() for filepath in selected_files: filepath = os.path.normpath(filepath) diff --git a/blacs/tab_base_classes.py b/blacs/tab_base_classes.py index 088db4d2..863c5426 100644 --- a/blacs/tab_base_classes.py +++ b/blacs/tab_base_classes.py @@ -1242,7 +1242,7 @@ def find_by_name(self, device_name): window.show() def run(): - app.exec_() + app.exec() tab1.close_tab() tab2.close_tab() sys.exit(run()) From 5d6c4b9cdbedfdc4714619e142973b656a489b4e Mon Sep 17 00:00:00 2001 From: David Meyer Date: Tue, 2 Dec 2025 13:56:33 -0500 Subject: [PATCH 06/11] Update default theme stylesheet to conform to modern conventions better. --- blacs/plugins/theme/__init__.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/blacs/plugins/theme/__init__.py b/blacs/plugins/theme/__init__.py index fa82befc..fb5506d4 100644 --- a/blacs/plugins/theme/__init__.py +++ b/blacs/plugins/theme/__init__.py @@ -24,8 +24,8 @@ DEFAULT_STYLESHEET = """DigitalOutput { font-size: 12px; - background-color: rgb(50,100,50,255); - border: 1px solid rgb(50,100,50,128); + background-color: rgb(50,100,50); + border: 1px solid rgb(50,100,50); border-radius: 3px; padding: 2px; color: #202020; @@ -37,7 +37,7 @@ } DigitalOutput:disabled{ - background-color: rgb(50,100,50,128); + background-color: rgb(50,100,50); color: #505050; } @@ -56,14 +56,15 @@ DigitalOutput:checked:disabled{ background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 rgba(32,200,32,128), stop: 1 rgba(32,255,32,128)); + stop: 0 rgba(32,200,32), stop: 1 rgba(32,255,32)); + border: 1px solid #8f8f91; color: #606060; } InvertedDigitalOutput { font-size: 12px; - background-color: rgb(70,100,170,255); - border: 1px solid rgb(70,100,170,128); + background-color: rgb(70,100,170); + border: 1px solid rgb(70,100,170); border-radius: 3px; padding: 2px; color: #202020; @@ -75,7 +76,7 @@ } InvertedDigitalOutput:disabled{ - background-color: rgba(70,100,170,128); + background-color: rgba(70,100,170); color: #505050; } @@ -94,7 +95,8 @@ InvertedDigitalOutput:checked:disabled{ background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 rgba(50,150,221,128), stop: 1 rgba(32,192,255,128)); + stop: 0 rgba(50,150,221), stop: 1 rgba(32,192,255)); + border: 1px solid #8f8f91; color: #606060; } """ From 71878a3583596629e9c860ef5c46157beddd19b9 Mon Sep 17 00:00:00 2001 From: David Meyer Date: Tue, 2 Dec 2025 14:43:56 -0500 Subject: [PATCH 07/11] Bump minimum required version of qtutils --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6a973650..ce324009 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "desktop-app>=0.1.2", "labscript_utils>=3.1.0b1", "runmanager>=3.0.0", - "qtutils>=2.2.2", + "qtutils>=4.0.0", "zprocess>=2.14.1", ] dynamic = ["version"] From 579d1a70b93b61d035926ee0883d8c5c9c500a94 Mon Sep 17 00:00:00 2001 From: David Meyer Date: Tue, 2 Dec 2025 16:00:34 -0500 Subject: [PATCH 08/11] Replace `QPushButton` and `QToolButton` colors with palette references. This ensures the colors follow dark/light themes in PySide6 --- blacs/main.ui | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/blacs/main.ui b/blacs/main.ui index d79727e1..feac94e1 100644 --- a/blacs/main.ui +++ b/blacs/main.ui @@ -26,28 +26,28 @@ QPushButton:hover { background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 #f6f7fa, stop: 1 #dadbde); - border: 1px solid #8f8f91; + stop: 0 palette(light), stop: 1 palette(window)); + border: 1px solid palette(dark); border-radius: 3px; } QPushButton:pressed { background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 #dadbde, stop: 1 #f6f7fa); - border: 1px solid #8f8f91; + stop: 0 palette(window), stop: 1 palette(light)); + border: 1px solid palette(dark); border-radius: 3px; } QPushButton:checked { - background-color: #dadbde; - border: 1px solid #8f8f91; + background-color: palette(window); + border: 1px solid palette(dark); border-radius: 3px; } QPushButton:hover:checked { background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 #dadbde, stop: 1 #f6f7fa); - border: 1px solid #8f8f91; + stop: 0 palette(window), stop: 1 palette(light)); + border: 1px solid palette(dark); border-radius: 3px; } @@ -58,28 +58,28 @@ QToolButton { QToolButton:hover { background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 #f6f7fa, stop: 1 #dadbde); - border: 1px solid #8f8f91; + stop: 0 palette(light), stop: 1 palette(window)); + border: 1px solid palette(dark); border-radius: 3px; } QToolButton:pressed { background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 #dadbde, stop: 1 #f6f7fa); - border: 1px solid #8f8f91; + stop: 0 palette(window), stop: 1 palette(light)); + border: 1px solid palette(dark); border-radius: 3px; } QToolButton:checked { - background-color: #dadbde; - border: 1px solid #8f8f91; + background-color: palette(window); + border: 1px solid palette(dark); border-radius: 3px; } QToolButton:hover:checked { background-color: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, - stop: 0 #dadbde, stop: 1 #f6f7fa); - border: 1px solid #8f8f91; + stop: 0 palette(window), stop: 1 palette(light)); + border: 1px solid palette(dark); border-radius: 3px; } From 8b41ebd79d1c99321963af6e5907d1ec083d9a83 Mon Sep 17 00:00:00 2001 From: David Meyer Date: Wed, 3 Dec 2025 11:25:41 -0500 Subject: [PATCH 09/11] Ensure that device tab bars text color respects theming --- blacs/tab_base_classes.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/blacs/tab_base_classes.py b/blacs/tab_base_classes.py index 863c5426..869c2806 100644 --- a/blacs/tab_base_classes.py +++ b/blacs/tab_base_classes.py @@ -25,7 +25,7 @@ from bisect import insort from qtutils.qt.QtCore import Qt, QTimer -from qtutils.qt.QtGui import QIcon, QColor +from qtutils.qt.QtGui import QIcon, QColor, QPalette from qtutils.qt.QtWidgets import QLabel, QWidget, QPushButton, QApplication, QVBoxLayout from qtutils import inmain_decorator, inmain, inthread, UiLoader @@ -261,6 +261,8 @@ def __init__(self,notebook,settings,restart=False): # Load the UI self._ui = UiLoader().load(os.path.join(BLACS_DIR, 'tab_frame.ui')) + # set tab text color from palette to respect OS theme changes + self._tab_text_colour = self._ui.palette().color(QPalette.ColorRole.Text) self._layout = self._ui.device_layout self._device_widget = self._ui.device_controls self._changed_widget = self._ui.changed_widget @@ -412,7 +414,8 @@ def _update_error_and_tab_icon(self): self._tab_icon = self.ICON_ERROR else: self._ui.notresponding.hide() - self._tab_text_colour = 'black' + # set tab text color from palette to respect OS theme changes + self._tab_text_colour = self._ui.palette().color(QPalette.ColorRole.Text) if self.state == 'idle': self._tab_icon = self.ICON_OK else: From f050e4cf0d2caf24952cab739ecb7442c06243a0 Mon Sep 17 00:00:00 2001 From: David Meyer Date: Wed, 3 Dec 2025 11:26:06 -0500 Subject: [PATCH 10/11] Tweak `device_base_class.py` testing code to properly handle multiple tabs. --- blacs/device_base_class.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/blacs/device_base_class.py b/blacs/device_base_class.py index 038a9afe..b5027664 100644 --- a/blacs/device_base_class.py +++ b/blacs/device_base_class.py @@ -957,18 +957,20 @@ class MyWindow(QWidget): def __init__(self,*args,**kwargs): QWidget.__init__(self,*args,**kwargs) self.are_we_closed = False + self.my_tabs = [] def closeEvent(self,event): if not self.are_we_closed: event.ignore() - self.my_tab.close_tab() + for tab in self.my_tabs: + tab.close_tab() self.are_we_closed = True QTimer.singleShot(1000,self.close) else: event.accept() def add_my_tab(self,tab): - self.my_tab = tab + self.my_tabs.append(tab) app = QApplication(sys.argv) window = MyWindow() @@ -979,6 +981,7 @@ def add_my_tab(self,tab): tab1 = MyDAQTab(notebook,settings = {'device_name': 'ni_pcie_6363_0', 'connection_table':connection_table}) tab2 = MyDummyTab(notebook,settings = {'device_name': 'intermediate_device', 'connection_table':connection_table}) window.add_my_tab(tab1) + window.add_my_tab(tab2) window.show() def run(): app.exec() From 55b6ed94dabb3f540c3008bafee5f1d8d5edf5a0 Mon Sep 17 00:00:00 2001 From: David Meyer Date: Thu, 11 Dec 2025 17:11:46 -0500 Subject: [PATCH 11/11] Ensure that OS theme changes propagate through entire GUI for PySide6 only. --- blacs/__main__.py | 33 ++++++++++++++++++++++++++++++--- blacs/tab_base_classes.py | 4 ++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/blacs/__main__.py b/blacs/__main__.py index 0ae35b30..0387ec12 100644 --- a/blacs/__main__.py +++ b/blacs/__main__.py @@ -39,14 +39,15 @@ import qtutils from qtutils import inmain_decorator, inmain_later, inmain, inthread, UiLoader import qtutils.icons -from qtutils.qt.QtCore import qVersion, QTimer, Qt -from qtutils.qt.QtGui import QIcon +from qtutils.qt.QtCore import qVersion, QTimer, Qt, QEvent +from qtutils.qt.QtGui import QIcon, QPalette, QColor from qtutils.qt.QtWidgets import ( QMainWindow, QToolButton, QMessageBox, QFileDialog, - QApplication + QApplication, + QWidget ) from qtutils.qt import QT_ENV PYQT_VERSION_STR = importlib.metadata.version(QT_ENV) @@ -144,6 +145,23 @@ def closeEvent(self, event): QTimer.singleShot(100,self.close) + def changeEvent(self, event): + + # theme update only for PySide6 + if QT_ENV == 'PySide6' and event.type() == QEvent.Type.ThemeChange: + for widget in self.findChildren(QWidget): + # Complex widgets, like TreeView and TableView require triggering styleSheet and palette updates + widget.setStyleSheet(widget.styleSheet()) + widget.setPalette(widget.palette()) + # tab header text colors have to be done explicitly by tab + # because they use setTabTextColor + app = QApplication.instance() + self.blacs.update_all_tab_icon_and_text( + app.palette().color(QPalette.ColorRole.Text) + ) + + return super().changeEvent(event) + class EasterEggButton(QToolButton): def __init__(self): @@ -533,6 +551,15 @@ def update_all_tab_settings(self,settings,tab_data): self.settings_dict[tab_name]["saved_data"] = tab_data[tab_name]['data'] if tab_name in tab_data else {} tab.update_from_settings(self.settings_dict[tab_name]) + def update_all_tab_icon_and_text(self, text_colour): + # used to repaint tab header text after theme change + + for tab in self.tablist.values(): + if tab._tab_text_colour == QColor('red'): + # ensure error tabs keep their red text + continue + tab._tab_text_colour = text_colour + tab.set_tab_icon_and_colour() def on_load_front_panel(self,*args,**kwargs): # get the file: diff --git a/blacs/tab_base_classes.py b/blacs/tab_base_classes.py index 869c2806..47f1b66a 100644 --- a/blacs/tab_base_classes.py +++ b/blacs/tab_base_classes.py @@ -406,7 +406,7 @@ def _update_error_and_tab_icon(self): self._ui.error_message.setHtml(prefix+self._not_responding_error_message+self._error+suffix) if self._error or self._not_responding_error_message: self._ui.notresponding.show() - self._tab_text_colour = 'red' + self._tab_text_colour = QColor('red') if self.error_message: if self.state == 'fatal error': self._tab_icon = self.ICON_FATAL_ERROR @@ -436,7 +436,7 @@ def set_tab_icon_and_colour(self): return icon = QIcon(self._tab_icon) self.notebook.tabBar().setTabIcon(currentpage, icon) - self.notebook.tabBar().setTabTextColor(currentpage, QColor(self._tab_text_colour)) + self.notebook.tabBar().setTabTextColor(currentpage, self._tab_text_colour) def get_tab_layout(self): return self._layout