import os import shutil import sys import subprocess from qtpy import QtGui, QtCore, QtWidgets, uic import traceback from qtvcp.widgets.widget_baseclass import _HalWidgetBase # Set up logging from . import logger log = logger.getLogger(__name__) # Force the log level for this module #log.setLevel(logger.DEBUG) # One of DEBUG, INFO, WARNING, ERROR, CRITICAL # PyQt6 raises KeyError when a .ui file signal connection references an overloaded # signal type that no longer exists (e.g. 'bool' subscript on a non-overloaded signal). # Patch _handle_connections to skip bad connections rather than aborting the whole load. try: from PyQt6.uic import uiparser as _uiparser from PyQt6 import QtCore as _QtCore def _tolerant_handle_connections(self, el): def name2object(obj): if obj == self.uiname: return self.toplevelWidget else: return getattr(self.toplevelWidget, obj) for conn in el: signal = conn.findtext('signal') signal_name, signal_args = signal.split('(') signal_args = signal_args[:-1].replace(' ', '') sender = name2object(conn.findtext('sender')) bound_signal = getattr(sender, signal_name) slot = self.factory.getSlot(name2object(conn.findtext('receiver')), conn.findtext('slot').split('(')[0]) try: if signal_args == '': bound_signal.connect(slot) else: signal_args_list = signal_args.split(',') if len(signal_args_list) == 1: bound_signal[signal_args_list[0]].connect(slot) else: bound_signal[tuple(signal_args_list)].connect(slot) except KeyError: log.warning('Skipping ui signal connection {}({}) - no matching PyQt6 overload'.format( signal_name, signal_args)) _QtCore.QMetaObject.connectSlotsByName(self.toplevelWidget) _uiparser.UIParser._handle_connections = _tolerant_handle_connections except ImportError: pass # Not using PyQt6 class Trampoline(object): def __init__(self, methods): self.methods = methods def __call__(self, *a, **kw): for m in self.methods: m(*a, **kw) class MyEventFilter(QtCore.QObject): def __init__(self, window): super(MyEventFilter, self).__init__() self.w = window self.has_key_p_handler = 'keypress_event__' in dir(self.w.handler_instance) self.has_key_r_handler = 'keyrelease_event__' in dir(self.w.handler_instance) self.has_process_key_handler = 'processed_key_event__' in dir(self.w.handler_instance) def process_event(self, event, pressed): shift = ctrl = False if (event.modifiers() & QtCore.Qt.ShiftModifier): shift = True if (event.modifiers() & QtCore.Qt.ControlModifier): ctrl = True code = event.key() key = event.text() return (pressed, key, code, shift, ctrl) def eventFilter(self, receiver, event): # in pyqt5 QWindow gets all events before the widgets inside it do # we need the widgets inside it to get their events first # this line line provides that. # (pyqt4 did not require this) if isinstance(receiver, QtGui.QWindow): return super(MyEventFilter, self).eventFilter(receiver, event) # Run in try statement so if we want to event to run through normal event routines, # we just raise an error and run the super class event handler. # This is necessary because if the widget (such as dialogs) are owned by c++ rather then python, # it causes an error and the keystrokes don't get to the widgets. try: if (event.type() == QtCore.QEvent.KeyPress): handled = False handled = self.w.keyPressTrap(event) if (self.has_key_p_handler): handled = self.w.handler_instance.keypress_event__(receiver, event) elif self.has_process_key_handler: p, k, c, s, ctrl = self.process_event(event, True) handled = self.w.handler_instance.processed_key_event__(receiver, event, p, k, c, s, ctrl) if handled: return True elif (event.type() == QtCore.QEvent.KeyRelease): handled = False handled = self.w.keyReleaseTrap(event) if (self.has_key_r_handler): handled = self.w.handler_instance.keyrelease_event__(event) elif self.has_process_key_handler: p, k, c, s, ctrl = self.process_event(event, False) handled = self.w.handler_instance.processed_key_event__(receiver, event, p, k, c, s, ctrl) if handled: return True elif event.type() in (QtCore.QEvent.FocusIn, QtCore.QEvent.FocusOut): self.w.handler_instance.processed_focus_event__(receiver, event) # Call Base Class Method to Continue Normal Event Processing return super(MyEventFilter, self).eventFilter(receiver, event) except: return super(MyEventFilter, self).eventFilter(receiver, event) class _VCPWindow(QtWidgets.QMainWindow): def __init__(self, halcomp=None, path=None): super(_VCPWindow, self).__init__() # only initialize once for all instances if self.__class__._instanceNum >= 1: return self.__class__._instanceNum += 1 self._idName = "TopObject" # initial name, will be replaced self.halcomp = halcomp self.has_closing_handler = False self.setFocus() self.PATHS = path self.PREFS_ = None self.originalCloseEvent_ = self.closeEvent self._halWidgetList = [] self._VCPWindowList = [] self._DialogList = [] self.settings = QtCore.QSettings('QtVcp', path.BASENAME) log.info('Qsettings file path: yellow<{}>'.format(self.settings.fileName())) # make an instance with embedded variables so they # are available to all subclassed objects _HalWidgetBase(halcomp, path, self) def registerHalWidget(self, widget): self._halWidgetList.append(widget) def getRegisteredHalWidgetList(self): return self._halWidgetList def registerDialog(self, widget): self._DialogList.append(widget) def getRegisteredDialogList(self): return self._DialogList # These catch events if using a plain VCP panel and there is no handler file def keyPressEvent(self, e): self.keyPressTrap(e) def keyReleaseEvent(self, e): self.keyReleaseTrap(e) # These can get class patched by xembed library to catch events def keyPressTrap(self, e): return False def keyReleaseTrap(self, e): return False # call closing function on main and embedded windows. def shutdown(self): for i in (self._VCPWindowList): if 'closing_cleanup__' in dir(i): log.debug('Calling handler file closing_cleanup__ function of {}.'.format(i._idName)) i.closing_cleanup__() def sync_qsettings(self): try: self.settings.sync() log.debug('Qsettings sync called:') except Exception as e: log.debug('Error with Qsettings sync function:\n {}'.format(e)) # resource files are compiled from the qrs file # with an installed version of linuxcnc we can't save the resource # file in the installed directory without being root, # so we always make a directory in the config folder to put it in def load_resources(self): def qrccompile(qrcname, qrcpy): log.info('Compiling qrc: {} to \n {}'.format(qrcname, qrcpy)) try: subprocess.call(["pyrcc5", "-o", "{}".format(qrcpy), "{}".format(qrcname)]) return except FileNotFoundError: pass try: rcc = shutil.which("pyside6-rcc") if not rcc: rcc = shutil.which("rcc", path="{}{}{}".format(os.getenv("PATH", ""), os.pathsep, "/usr/lib/qt6/libexec")) or "rcc" subprocess.call([rcc, "-g", "python", "-o", "{}".format(qrcpy), "{}".format(qrcname)]) # rcc generates PySide6 imports; rewrite to use qtpy with open(qrcpy, 'r') as f: content = f.read() content = content.replace('from PySide6 import QtCore', 'from qtpy import QtCore') with open(qrcpy, 'w') as f: f.write(content) except OSError as e: log.error( '{}, pyrcc5/rcc error. try in terminal: sudo apt install pyqt5-dev-tools or qt6-base-dev-tools to install dev tools'.format( e)) msg = QtWidgets.QMessageBox() msg.setIcon(QtWidgets.QMessageBox.Critical) msg.setText("QTvcp qrc compiling ERROR! ") msg.setInformativeText( 'Qrc Compile error, try: "sudo apt install pyqt5-dev-tools" or "qt6-base-dev-tools" to install dev tools') msg.setWindowTitle("Error") msg.setDetailedText('You can continue but some images may be missing') msg.setStandardButtons(QtWidgets.QMessageBox.Retry | QtWidgets.QMessageBox.Abort) msg.show() retval = msg.exec() if retval == QtWidgets.QMessageBox.Abort: # cancel button log.critical("Canceled from qrc compiling Error Dialog.\n") raise SystemError('pyrcc5/rcc compiling error: try: "sudo apt install pyqt5-dev-tools" or "qt6-base-dev-tools"') qrcname = self.PATHS.QRC qrcpy = self.PATHS.QRCPY # Is there a qrc file in directory? if qrcname is not None: qrcTime = os.stat(qrcname).st_mtime if qrcpy is not None and os.path.isfile(qrcpy): pyTime = os.stat(qrcpy).st_mtime # is py older then qrc file? if pyTime < qrcTime: qrccompile(qrcname, qrcpy) # there is a qrc file but no resources.py file...compile it else: if not os.path.isfile(qrcpy): # make the missing directory try: os.makedirs(os.path.split(qrcpy)[0]) except Exception as e: log.warning('Could not make directory {} resource file: {}'.format(os.path.split(qrcpy)[0], e)) qrccompile(qrcname, qrcpy) # is there a resource.py in the directory? # if so add a path to it so we can import it. if qrcpy is not None and os.path.isfile(qrcpy): try: sys.path.insert(0, os.path.split(qrcpy)[0]) import importlib importlib.import_module('resources', os.path.split(qrcpy)[0]) log.info('Imported resources.py file: yellow<{}>'.format(qrcpy)) except Exception as e: log.warning('Could not load {} resource file: yellow<{}>'.format(qrcpy, e)) else: log.info('No resource file to load: yellow<{}>'.format(qrcpy)) def instance(self, filename): self.load_resources() try: instance = uic.loadUi(filename, self) except AttributeError as e: formatted_lines = traceback.format_exc().splitlines() if 'slotname' in formatted_lines[-2]: log.critical('Missing slot name in handler file: {}'.format(e)) message = '''A widget in the ui file, was assigned a signal \ call to a missing function name in the handler file?\n There may be more. Some functions might not work.\n Python Error:\n {}'''.format(str(e)) rtn = QtWidgets.QMessageBox.critical(None, "QTVCP Error", message) else: log.critical(e) raise def apply_styles(self, fname=None): if self.PATHS.IS_SCREEN: DIR = self.PATHS.SCREENDIR BNAME = self.PATHS.BASENAME else: DIR = self.PATHS.PANELDIR BNAME = self.PATHS.BASENAME # apply one word system theme if fname in (list(QtWidgets.QStyleFactory.keys())): QtWidgets.QApplication.instance().setStyle(fname) log.info('Applied System Style name: yellow<{}>'.format(fname)) return # Check for Preference file specified qss if fname is None: if not self.PREFS_ is None: path = self.PREFS_.getpref('style_QSS_Path', 'DEFAULT', str, 'BOOK_KEEPING') if path.lower() == 'none': return if not path.lower() == 'default': fname = path # check for default base named qss file if fname is None: if self.PATHS.QSS is not None: fname = self.PATHS.QSS # if qss file is not a file, try expanding/adding a leading path if fname is not None: if not os.path.isfile(fname): temp = os.path.join(os.path.expanduser(fname)) qssname = os.path.join(DIR, BNAME, fname + '.qss') if not os.path.isfile(fname): qssname = temp else: qssname = fname else: return try: qss_file = open(qssname).read() # qss files aren't friendly about changing image paths qss_file = qss_file.replace('url(:/newPrefix/images', 'url({}'.format(self.PATHS.IMAGEDIR)) self.setStyleSheet(qss_file) log.info('Applied Style Sheet path: yellow<{}>'.format(qssname)) return except: if fname: themes = '' log.error('QSS Filepath Error: {}'.format(qssname)) log.error("{} theme not available.".format(fname)) current_theme = str(QtWidgets.QApplication.instance().style().objectName()) for i in (list(QtWidgets.QStyleFactory.keys())): themes += (', {}'.format(i)) log.error('QTvcp Available system themes: green<{}> {}'.format(current_theme, themes)) def load_extension(self, handlerpath, obj = ''): methods, self[obj].handler_module, self[obj].handler_instance = self._load_handlers([handlerpath], self.halcomp, self[obj]) for i in methods: self[obj][i] = methods[i] # See if the handler file has a closing function to call first self[obj].has_closing_handler = 'closing_cleanup__' in dir(self[obj].handler_instance) def _load_handlers(self, usermod, halcomp, widgets): hdl_func = 'get_handlers' mod = None object = None def add_handler(method, f): if method in handlers: handlers[method].append(f) else: handlers[method] = [f] handlers = {} for u in usermod: (directory, filename) = os.path.split(u) (basename, extension) = os.path.splitext(filename) if directory == '': directory = '.' if directory not in sys.path: sys.path.insert(0, directory) log.debug('Adding import dir: yellow<{}>'.format(directory)) try: mod = __import__(basename) # inject/class patch function to read an override file # this will be called from qtvcp.py later mod.HandlerClass.call_user_command_ = self.call_user_command_ except ImportError as e: log.critical("Module '{}' skipped - import error: ".format(basename), exc_info=e) raise continue log.debug("Module '{}' imported green".format(mod.__name__)) try: # look for 'get_handlers' function h = getattr(mod, hdl_func, None) if h and callable(h): log.debug("Module '{}' : '{}' function found.".format(mod.__name__, hdl_func)) objlist = h(halcomp, widgets, self.PATHS) # this sets the handler class signature else: # the module has no get_handlers() callable. # in this case we permit any callable except class Objects in the module to register as handler log.debug("Module '{}': no '{}' function - registering only functions as callbacks." .format(mod.__name__, hdl_func)) objlist = [mod] # extract callback candidates for object in objlist: log.debug("Registering handlers in module {} object {}".format(mod.__name__, object)) if isinstance(object, dict): methods = list(dict.items()) else: methods = [(n, getattr(object, n, None)) for n in dir(object)] for method, f in methods: if method.startswith('_'): continue if callable(f): log.verbose("Register callback '{}'".format(method)) add_handler(method, f) except Exception as e: log.exception("Trouble looking for handlers in '{}':".format(basename), exc_info=e) # we require a working handler file! raise # Wrap lists in Trampoline, unwrap single functions for n, v in list(handlers.items()): if len(v) == 1: handlers[n] = v[0] else: handlers[n] = Trampoline(v) return handlers, mod, object # this is the function that is injected into the handler file to read an override file # this will be called from qtvcp.py later def call_user_command_(self, klass, rcfile = "~/.qtvcprc"): # substitute for any keywords rcfile.replace('CONFIGFOLDER',self.PATHS.CONFIGPATH) rcfile.replace('WORKINGFOLDER',self.PATHS.WORKINGDIR) rcfile = os.path.expanduser(rcfile) if os.path.exists(rcfile): log.info('Handler Override file found at: yellow<{}>'.format(rcfile)) try: local = {'self': klass, 'rcfile': rcfile} exec(compile(open(rcfile, "rb").read(), rcfile, 'exec'),local) except Exception as e: log.exception(e) else: log.info('No Handler Override file at: yellow<{}>'.format(rcfile)) # facilitate the main screen to create another window panel def makeMainPage(self, name): return MainPage(main=self,name=name ) # This is used to build a window that a qtvcp panel can be built from. # The idea is to embed the panel into the main screen # This allow the completely separate panels to be incorporated in # as the user sees fit. # the big deal is to still allow the panel to have a functioning handler file class MainPage(QtWidgets.QMainWindow): def __init__(self, *args, **kwargs): super().__init__() for key, value in kwargs.items(): if key =='main': self.halcomp = value.halcomp self.PATHS = value.PATHS self.MAIN = value elif key == 'name': self._idName = value def instance(self, filename): try: instance = uic.loadUi(filename, self) except AttributeError as e: formatted_lines = traceback.format_exc().splitlines() if 'slotname' in formatted_lines[-2]: log.critical('{}: Missing slot name in handler file: {}'.format(self._idName, e)) message = '''A widget in the ui file, {} was assigned a signal \ call to a missing function name in the handler file?\n There may be more. Some functions might not work.\n Python Error:\n {}'''.format(self._idName, str(e)) rtn = QtWidgets.QMessageBox.critical(None, "QTVCP Error", message) else: log.critical(e) raise def load_extension(self, handlerpath): methods, self.handler_module, self.handler_instance = self._load_handlers([handlerpath], self.halcomp, self) for i in methods: self[i] = methods[i] # See if the handler file has a closing function to call first self.has_closing_handler = 'closing_cleanup__' in dir(self.handler_instance) def _load_handlers(self, usermod, halcomp, widgets): hdl_func = 'get_handlers' mod = None object = None def add_handler(method, f): if method in handlers: handlers[method].append(f) else: handlers[method] = [f] handlers = {} for u in usermod: (directory, filename) = os.path.split(u) (basename, extension) = os.path.splitext(filename) if directory == '': directory = '.' if directory not in sys.path: sys.path.insert(0, directory) log.debug('{}: adding import dir: yellow<{}>'.format(self._idName, directory)) try: mod = __import__(basename) except ImportError as e: log.critical("{}: module '{}' skipped - import error: " .format(self._idName, basename), exc_info=e) raise continue log.debug("{}: module '{}' imported green".format(self._idName, mod.__name__)) try: # look for 'get_handlers' function h = getattr(mod, hdl_func, None) if h and callable(h): log.debug("{}: module '{}' : '{}' function found".format(self._idName, mod.__name__, hdl_func)) objlist = h(halcomp, widgets, self.PATHS) # this sets the handler class signature else: # the module has no get_handlers() callable. # in this case we permit any callable except class Objects in the module to register as handler log.debug("{}: module '{}': no '{}' function - registering only functions as callbacks" .format(self._idName, mod.__name__, hdl_func)) objlist = [mod] # extract callback candidates for object in objlist: log.debug("{}: Registering handlers in module {} object {}" .format(self._idName, mod.__name__, object)) if isinstance(object, dict): methods = list(dict.items()) else: methods = [(n, getattr(object, n, None)) for n in dir(object)] for method, f in methods: if method.startswith('_'): continue if callable(f): log.debug("{}: Register callback '{}'".format(self._idName, method)) add_handler(method, f) except Exception as e: log.exception("{}: Trouble looking for handlers in '{}':" .format(self._idName, basename), exc_info=e) # we require a working handler file! raise # Wrap lists in Trampoline, unwrap single functions for n, v in list(handlers.items()): if len(v) == 1: handlers[n] = v[0] else: handlers[n] = Trampoline(v) return handlers, mod, object def __getitem__(self, item): return getattr(self, item) def __setitem__(self, item, value): return setattr(self, item, value) class VCPWindow(_VCPWindow): _instance = [None] _instanceNum = 0 def __new__(cls, *args, **kwargs): if not cls._instance[0]: cls._instance[0] = _VCPWindow.__new__(cls, *args, **kwargs) return cls._instance[0] def __getitem__(self, item): if item == '': return self return getattr(self, item) def __setitem__(self, item, value): return setattr(self, item, value)