From a4def8da33575e1a0ab1d2e687acb7b9b5b78842 Mon Sep 17 00:00:00 2001 From: Kendon Bell Date: Fri, 9 Jan 2026 13:48:26 +1300 Subject: [PATCH 1/3] Refactor Qt imports to use compatibility layer and update requirements for PySide6 on macOS. Adjust event handling and UI components in HUD and settings windows to utilize new Qt attributes. Ensure consistent application execution method across modules. --- castervoice/asynch/hmc/h_launch.py | 6 +-- castervoice/asynch/hmc/homunculus.py | 53 ++++++++++-------- castervoice/asynch/hud.py | 81 ++++++++++++++++------------ castervoice/asynch/settingswindow.py | 56 +++++++++++-------- castervoice/lib/qt.py | 41 ++++++++++++++ requirements-mac-linux.txt | 3 +- 6 files changed, 157 insertions(+), 83 deletions(-) create mode 100644 castervoice/lib/qt.py diff --git a/castervoice/asynch/hmc/h_launch.py b/castervoice/asynch/hmc/h_launch.py index 60fbcf6ec..9a2f1c5b1 100644 --- a/castervoice/asynch/hmc/h_launch.py +++ b/castervoice/asynch/hmc/h_launch.py @@ -52,17 +52,17 @@ def _get_title(hmc_type): def main(): - import PySide2.QtWidgets + from castervoice.lib.qt import QtWidgets, qapp_exec from castervoice.asynch.hmc.homunculus import Homunculus from castervoice.lib.merge.communication import Communicator server_address = (Communicator.LOCALHOST, Communicator().com_registry["hmc"]) # Enabled by default logging causes RPC to malfunction when the GUI runs on # pythonw. Explicitly disable logging for the XML server. server = SimpleXMLRPCServer(server_address, logRequests=False, allow_none=True) - app = PySide2.QtWidgets.QApplication(sys.argv) + app = QtWidgets.QApplication(sys.argv) window = Homunculus(server, sys.argv) window.show() - exit_code = app.exec_() + exit_code = qapp_exec(app) server.shutdown() sys.exit(exit_code) diff --git a/castervoice/asynch/hmc/homunculus.py b/castervoice/asynch/hmc/homunculus.py index 882558c8c..49484c9c6 100644 --- a/castervoice/asynch/hmc/homunculus.py +++ b/castervoice/asynch/hmc/homunculus.py @@ -4,23 +4,6 @@ import dragonfly -# TODO: Remove this try wrapper when CI server supports Qt -try: - import PySide2.QtCore - from PySide2.QtWidgets import QApplication - from PySide2.QtWidgets import QCheckBox - from PySide2.QtWidgets import QDialog - from PySide2.QtWidgets import QFileDialog - from PySide2.QtWidgets import QFormLayout - from PySide2.QtWidgets import QLabel - from PySide2.QtWidgets import QLineEdit - from PySide2.QtWidgets import QScrollArea - from PySide2.QtWidgets import QTextEdit - from PySide2.QtWidgets import QVBoxLayout - from PySide2.QtWidgets import QWidget -except ImportError: - sys.exit(0) - try: # Style C -- may be imported into Caster, or externally BASE_PATH = os.path.realpath(__file__).rsplit(os.path.sep + "castervoice", 1)[0] if BASE_PATH not in sys.path: @@ -28,7 +11,31 @@ finally: from castervoice.lib import settings -RPC_DIR_EVENT = PySide2.QtCore.QEvent.Type(PySide2.QtCore.QEvent.registerEventType(-1)) +try: + from castervoice.lib.qt import QtCore, QtWidgets, qt_attr +except ImportError: + sys.exit(0) + +QApplication = QtWidgets.QApplication +QCheckBox = QtWidgets.QCheckBox +QDialog = QtWidgets.QDialog +QFileDialog = QtWidgets.QFileDialog +QFormLayout = QtWidgets.QFormLayout +QLabel = QtWidgets.QLabel +QLineEdit = QtWidgets.QLineEdit +QScrollArea = QtWidgets.QScrollArea +QTextEdit = QtWidgets.QTextEdit +QVBoxLayout = QtWidgets.QVBoxLayout +QWidget = QtWidgets.QWidget + +ALIGN_CENTER = qt_attr(QtCore, ("Qt", "AlignCenter"), ("Qt", "AlignmentFlag", "AlignCenter")) +SHOW_DIRS_ONLY = qt_attr( + QtWidgets, + ("QFileDialog", "ShowDirsOnly"), + ("QFileDialog", "Option", "ShowDirsOnly"), +) + +RPC_DIR_EVENT = QtCore.QEvent.Type(QtCore.QEvent.registerEventType(-1)) class Homunculus(QDialog): @@ -64,7 +71,7 @@ def setup_base_window(self, data=None): self.setWindowTitle(settings.HOMUNCULUS_VERSION) self.data = data.split("|") if data else [0, 0] label = QLabel(" ".join(self.data[0].split(settings.HMC_SEPARATOR))) if data else QLabel("Enter response then say 'complete'") # pylint: disable=no-member - label.setAlignment(PySide2.QtCore.Qt.AlignCenter) + label.setAlignment(ALIGN_CENTER) self.ext_box = QTextEdit() self.mainLayout.addWidget(label) self.mainLayout.addWidget(self.ext_box) @@ -98,7 +105,7 @@ def setup_recording_window(self, history): self.setGeometry(x, y, 640, 480) self.setWindowTitle(settings.HOMUNCULUS_VERSION + settings.HMC_TITLE_RECORDING) label = QLabel("Macro Recording Options") - label.setAlignment(PySide2.QtCore.Qt.AlignCenter) + label.setAlignment(ALIGN_CENTER) self.mainLayout.addWidget(label) label = QLabel("Command Words:") self.word_box = QLineEdit() @@ -108,7 +115,7 @@ def setup_recording_window(self, history): self.repeatable = QCheckBox("Make Repeatable") self.mainLayout.addWidget(self.repeatable) label = QLabel("Dictation History") - label.setAlignment(PySide2.QtCore.Qt.AlignCenter) + label.setAlignment(ALIGN_CENTER) self.mainLayout.addWidget(label) self.word_state = [] cb_number = 1 @@ -144,7 +151,7 @@ def check_range_of_boxes(self, details): self.word_state[i].setChecked(True) def ask_directory(self): - result = QFileDialog.getExistingDirectory(self, "Please select directory", os.environ["HOME"], QFileDialog.ShowDirsOnly) + result = QFileDialog.getExistingDirectory(self, "Please select directory", os.environ["HOME"], SHOW_DIRS_ONLY) self.word_box.setText(result) def event(self, event): @@ -214,7 +221,7 @@ def xmlrpc_get_message_confirm(self): def xmlrpc_do_action_directory(self, action, details=None): if action == "dir": - PySide2.QtCore.QCoreApplication.postEvent(self, PySide2.QtCore.QEvent(RPC_DIR_EVENT)) + QtCore.QCoreApplication.postEvent(self, QtCore.QEvent(RPC_DIR_EVENT)) def xmlrpc_get_message_directory(self): response = None diff --git a/castervoice/asynch/hud.py b/castervoice/asynch/hud.py index e82d0236f..1129be09d 100644 --- a/castervoice/asynch/hud.py +++ b/castervoice/asynch/hud.py @@ -9,16 +9,8 @@ import signal import sys import threading -import PySide2.QtCore -import PySide2.QtGui import dragonfly from xmlrpc.server import SimpleXMLRPCServer -from PySide2.QtWidgets import QApplication -from PySide2.QtWidgets import QMainWindow -from PySide2.QtWidgets import QTextEdit -from PySide2.QtWidgets import QTreeView -from PySide2.QtWidgets import QVBoxLayout -from PySide2.QtWidgets import QWidget try: # Style C -- may be imported into Caster, or externally BASE_PATH = os.path.realpath(__file__).rsplit(os.path.sep + "castervoice", 1)[0] if BASE_PATH not in sys.path: @@ -26,19 +18,38 @@ finally: from castervoice.lib.merge.communication import Communicator from castervoice.lib import settings - -CLEAR_HUD_EVENT = PySide2.QtCore.QEvent.Type(PySide2.QtCore.QEvent.registerEventType(-1)) -HIDE_HUD_EVENT = PySide2.QtCore.QEvent.Type(PySide2.QtCore.QEvent.registerEventType(-1)) -SHOW_HUD_EVENT = PySide2.QtCore.QEvent.Type(PySide2.QtCore.QEvent.registerEventType(-1)) -HIDE_RULES_EVENT = PySide2.QtCore.QEvent.Type(PySide2.QtCore.QEvent.registerEventType(-1)) -SHOW_RULES_EVENT = PySide2.QtCore.QEvent.Type(PySide2.QtCore.QEvent.registerEventType(-1)) -SEND_COMMAND_EVENT = PySide2.QtCore.QEvent.Type(PySide2.QtCore.QEvent.registerEventType(-1)) - - -class RPCEvent(PySide2.QtCore.QEvent): + from castervoice.lib.qt import QtCore, QtGui, QtWidgets, qt_attr, qapp_exec + +QApplication = QtWidgets.QApplication +QMainWindow = QtWidgets.QMainWindow +QTextEdit = QtWidgets.QTextEdit +QTreeView = QtWidgets.QTreeView +QVBoxLayout = QtWidgets.QVBoxLayout +QWidget = QtWidgets.QWidget + +WINDOW_STAYS_ON_TOP_HINT = qt_attr( + QtCore, + ("Qt", "WindowStaysOnTopHint"), + ("Qt", "WindowType", "WindowStaysOnTopHint"), +) +TEXT_CURSOR_END = qt_attr( + QtGui, + ("QTextCursor", "End"), + ("QTextCursor", "MoveOperation", "End"), +) + +CLEAR_HUD_EVENT = QtCore.QEvent.Type(QtCore.QEvent.registerEventType(-1)) +HIDE_HUD_EVENT = QtCore.QEvent.Type(QtCore.QEvent.registerEventType(-1)) +SHOW_HUD_EVENT = QtCore.QEvent.Type(QtCore.QEvent.registerEventType(-1)) +HIDE_RULES_EVENT = QtCore.QEvent.Type(QtCore.QEvent.registerEventType(-1)) +SHOW_RULES_EVENT = QtCore.QEvent.Type(QtCore.QEvent.registerEventType(-1)) +SEND_COMMAND_EVENT = QtCore.QEvent.Type(QtCore.QEvent.registerEventType(-1)) + + +class RPCEvent(QtCore.QEvent): def __init__(self, type, text): - PySide2.QtCore.QEvent.__init__(self, type) + QtCore.QEvent.__init__(self, type) self._text = text @property @@ -52,29 +63,29 @@ class RulesWindow(QWidget): _MARGIN = 30 def __init__(self, text): - QWidget.__init__(self, f=(PySide2.QtCore.Qt.WindowStaysOnTopHint)) + QWidget.__init__(self, f=WINDOW_STAYS_ON_TOP_HINT) x = dragonfly.monitors[0].rectangle.dx - (RulesWindow._WIDTH + RulesWindow._MARGIN) y = 300 dx = RulesWindow._WIDTH dy = dragonfly.monitors[0].rectangle.dy - (y + 2 * RulesWindow._MARGIN) self.setGeometry(x, y, dx, dy) self.setWindowTitle("Active Rules") - rules_tree = PySide2.QtGui.QStandardItemModel() + rules_tree = QtGui.QStandardItemModel() rules_tree.setColumnCount(2) rules_tree.setHorizontalHeaderLabels(['phrase', 'action']) rules_dict = json.loads(text) rules = rules_tree.invisibleRootItem() for g in rules_dict: - gram = PySide2.QtGui.QStandardItem(g["name"]) if len(g["rules"]) > 1 else None + gram = QtGui.QStandardItem(g["name"]) if len(g["rules"]) > 1 else None for r in g["rules"]: - rule = PySide2.QtGui.QStandardItem(r["name"]) + rule = QtGui.QStandardItem(r["name"]) rule.setRowCount(len(r["specs"])) rule.setColumnCount(2) row = 0 for s in r["specs"]: phrase, _, action = s.partition('::') - rule.setChild(row, 0, PySide2.QtGui.QStandardItem(phrase)) - rule.setChild(row, 1, PySide2.QtGui.QStandardItem(action)) + rule.setChild(row, 0, QtGui.QStandardItem(phrase)) + rule.setChild(row, 1, QtGui.QStandardItem(action)) row += 1 if gram is None: rules.appendRow(rule) @@ -84,7 +95,7 @@ def __init__(self, text): rules.appendRow(gram) tree_view = QTreeView(self) tree_view.setModel(rules_tree) - tree_view.setColumnWidth(0, RulesWindow._WIDTH / 2) + tree_view.setColumnWidth(0, RulesWindow._WIDTH // 2) layout = QVBoxLayout() layout.addWidget(tree_view) self.setLayout(layout) @@ -97,7 +108,7 @@ class HUDWindow(QMainWindow): _MARGIN = 30 def __init__(self, server): - QMainWindow.__init__(self, flags=(PySide2.QtCore.Qt.WindowStaysOnTopHint)) + QMainWindow.__init__(self, flags=WINDOW_STAYS_ON_TOP_HINT) x = dragonfly.monitors[0].rectangle.dx - (HUDWindow._WIDTH + HUDWindow._MARGIN) y = HUDWindow._MARGIN dx = HUDWindow._WIDTH @@ -137,7 +148,7 @@ def event(self, event): # self.output.append('
') self.output.append(formatted_text) cursor = self.output.textCursor() - cursor.movePosition(PySide2.QtGui.QTextCursor.End) + cursor.movePosition(TEXT_CURSOR_END) self.output.setTextCursor(cursor) self.output.ensureCursorVisible() self.commands_count += 1 @@ -176,33 +187,33 @@ def setup_xmlrpc_server(self): def xmlrpc_clear(self): - PySide2.QtCore.QCoreApplication.postEvent(self, PySide2.QtCore.QEvent(CLEAR_HUD_EVENT)) + QtCore.QCoreApplication.postEvent(self, QtCore.QEvent(CLEAR_HUD_EVENT)) return 0 def xmlrpc_ping(self): return 0 def xmlrpc_hide_hud(self): - PySide2.QtCore.QCoreApplication.postEvent(self, PySide2.QtCore.QEvent(HIDE_HUD_EVENT)) + QtCore.QCoreApplication.postEvent(self, QtCore.QEvent(HIDE_HUD_EVENT)) return 0 def xmlrpc_show_hud(self): - PySide2.QtCore.QCoreApplication.postEvent(self, PySide2.QtCore.QEvent(SHOW_HUD_EVENT)) + QtCore.QCoreApplication.postEvent(self, QtCore.QEvent(SHOW_HUD_EVENT)) return 0 def xmlrpc_hide_rules(self): - PySide2.QtCore.QCoreApplication.postEvent(self, PySide2.QtCore.QEvent(HIDE_RULES_EVENT)) + QtCore.QCoreApplication.postEvent(self, QtCore.QEvent(HIDE_RULES_EVENT)) return 0 def xmlrpc_kill(self): QApplication.quit() def xmlrpc_send(self, text): - PySide2.QtCore.QCoreApplication.postEvent(self, RPCEvent(SEND_COMMAND_EVENT, text)) + QtCore.QCoreApplication.postEvent(self, RPCEvent(SEND_COMMAND_EVENT, text)) return len(text) def xmlrpc_show_rules(self, text): - PySide2.QtCore.QCoreApplication.postEvent(self, RPCEvent(SHOW_RULES_EVENT, text)) + QtCore.QCoreApplication.postEvent(self, RPCEvent(SHOW_RULES_EVENT, text)) return len(text) @@ -225,6 +236,6 @@ def handler(signum, frame): app = QApplication(sys.argv) window = HUDWindow(server) window.show() - exit_code = app.exec_() + exit_code = qapp_exec(app) server.shutdown() sys.exit(exit_code) diff --git a/castervoice/asynch/settingswindow.py b/castervoice/asynch/settingswindow.py index 46cdb943a..0fc76db3f 100644 --- a/castervoice/asynch/settingswindow.py +++ b/castervoice/asynch/settingswindow.py @@ -14,20 +14,21 @@ from castervoice.lib import settings from castervoice.lib.merge.communication import Communicator -from PySide2 import QtCore -from PySide2.QtGui import QPalette -from PySide2.QtWidgets import QApplication -from PySide2.QtWidgets import QDialogButtonBox -from PySide2.QtWidgets import QCheckBox -from PySide2.QtWidgets import QDialog -from PySide2.QtWidgets import QFormLayout -from PySide2.QtWidgets import QGroupBox -from PySide2.QtWidgets import QLabel -from PySide2.QtWidgets import QLineEdit -from PySide2.QtWidgets import QScrollArea -from PySide2.QtWidgets import QTabWidget -from PySide2.QtWidgets import QVBoxLayout -from PySide2.QtWidgets import QWidget +from castervoice.lib.qt import QtCore, QtGui, QtWidgets, qt_attr, qapp_exec + +QPalette = QtGui.QPalette +QApplication = QtWidgets.QApplication +QDialogButtonBox = QtWidgets.QDialogButtonBox +QCheckBox = QtWidgets.QCheckBox +QDialog = QtWidgets.QDialog +QFormLayout = QtWidgets.QFormLayout +QGroupBox = QtWidgets.QGroupBox +QLabel = QtWidgets.QLabel +QLineEdit = QtWidgets.QLineEdit +QScrollArea = QtWidgets.QScrollArea +QTabWidget = QtWidgets.QTabWidget +QVBoxLayout = QtWidgets.QVBoxLayout +QWidget = QtWidgets.QWidget @@ -39,8 +40,21 @@ NUMBER_SETTING = 16 BOOLEAN_SETTING = 32 -CONTROL_KEY = QtCore.Qt.Key_Meta if sys.platform == "darwin" else QtCore.Qt.Key_Control -SHIFT_TAB_KEY = int(QtCore.Qt.Key_Tab) + 1 +KEY_META = qt_attr(QtCore, ("Qt", "Key_Meta"), ("Qt", "Key", "Key_Meta")) +KEY_CONTROL = qt_attr(QtCore, ("Qt", "Key_Control"), ("Qt", "Key", "Key_Control")) +KEY_TAB = qt_attr(QtCore, ("Qt", "Key_Tab"), ("Qt", "Key", "Key_Tab")) + +CONTROL_KEY = int(KEY_META if sys.platform == "darwin" else KEY_CONTROL) +TAB_KEY = int(KEY_TAB) +SHIFT_TAB_KEY = TAB_KEY + 1 + +KEY_RELEASE_EVENT = qt_attr(QtCore, ("QEvent", "KeyRelease"), ("QEvent", "Type", "KeyRelease")) +PALETTE_MID = qt_attr(QtGui, ("QPalette", "Mid"), ("QPalette", "ColorRole", "Mid")) +FORM_GROW_ALL_NON_FIXED = qt_attr( + QtWidgets, + ("QFormLayout", "AllNonFixedFieldsGrow"), + ("QFormLayout", "FieldGrowthPolicy", "AllNonFixedFieldsGrow"), +) RPC_COMPLETE_EVENT = QtCore.QEvent.Type(QtCore.QEvent.registerEventType(-1)) @@ -83,11 +97,11 @@ def __init__(self, server): self.expiration.start() def event(self, event): - if event.type() == QtCore.QEvent.KeyRelease: + if event.type() == KEY_RELEASE_EVENT: if self.modifier == 1: curr = self.tabs.currentIndex() tabs_count = self.tabs.count() - if event.key() == QtCore.Qt.Key_Tab: + if event.key() == TAB_KEY: next = curr + 1 next = 0 if next == tabs_count else next self.tabs.setCurrentIndex(next) @@ -116,7 +130,7 @@ def keyReleaseEvent(self, event): def make_tab(self, title): area = QScrollArea() field = Field(area, title) - area.setBackgroundRole(QPalette.Mid) + area.setBackgroundRole(PALETTE_MID) area.setWidgetResizable(True) area.setWidget(self.add_fields(self, title, field)) self.fields.append(field) @@ -125,7 +139,7 @@ def make_tab(self, title): def add_fields(self, parent, title, field): tab = QWidget(parent) form = QFormLayout() - form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow) + form.setFieldGrowthPolicy(FORM_GROW_ALL_NON_FIXED) for label in sorted(settings.SETTINGS[title].keys()): value = settings.SETTINGS[title][label] subfield = Field(None, label) @@ -233,7 +247,7 @@ def main(): app = QApplication(sys.argv) window = SettingsDialog(server) window.show() - exit_code = app.exec_() + exit_code = qapp_exec(app) server.shutdown() sys.exit(exit_code) diff --git a/castervoice/lib/qt.py b/castervoice/lib/qt.py new file mode 100644 index 000000000..f4e735647 --- /dev/null +++ b/castervoice/lib/qt.py @@ -0,0 +1,41 @@ +# pylint: disable=import-error,no-name-in-module + +""" +Minimal PySide2/PySide6 compatibility helpers. +""" + + +try: + from PySide2 import QtCore, QtGui, QtWidgets # type: ignore + QT_API = "PySide2" +except ImportError: # pragma: no cover + from PySide6 import QtCore, QtGui, QtWidgets # type: ignore + QT_API = "PySide6" + + +def qt_attr(root, *paths): + """ + Return the first attribute path that exists on `root`. + + `paths` should be tuples of attribute names, e.g. ("Qt", "Key", "Key_Tab"). + """ + last_error = None + for path in paths: + try: + obj = root + for name in path: + obj = getattr(obj, name) + return obj + except AttributeError as exc: + last_error = exc + if last_error is None: + raise AttributeError("qt_attr() requires at least one path") + raise last_error + + +def qapp_exec(app): + exec_fn = getattr(app, "exec", None) or getattr(app, "exec_", None) + if exec_fn is None: + raise AttributeError("QApplication has no exec/exec_ method") + return exec_fn() + diff --git a/requirements-mac-linux.txt b/requirements-mac-linux.txt index a58788fc2..0db30fb41 100644 --- a/requirements-mac-linux.txt +++ b/requirements-mac-linux.txt @@ -6,6 +6,7 @@ mock>=3.0.5 appdirs>=1.4.3 scandir>=1.10.0 pylint -PySide2>=5.14 +PySide6>=6.5; platform_system == "Darwin" +PySide2>=5.14; platform_system != "Darwin" six g2p_en From 8a6a61fd058718f96be5911dc5389d8e244f0296 Mon Sep 17 00:00:00 2001 From: Kendon Bell Date: Fri, 9 Jan 2026 14:25:55 +1300 Subject: [PATCH 2/3] Update requirements for cross-platform compatibility and enhance error handling in text manipulation functions. Adjust Dragonfly import logic to handle missing natlink gracefully and improve regex matching for dictation and character modes. --- castervoice/lib/ctrl/mgr/engine_manager.py | 16 ++- .../text_manipulation_support.py | 133 ++++++++++++------ requirements-mac-linux.txt | 3 +- 3 files changed, 104 insertions(+), 48 deletions(-) diff --git a/castervoice/lib/ctrl/mgr/engine_manager.py b/castervoice/lib/ctrl/mgr/engine_manager.py index c95c85631..305a468cb 100644 --- a/castervoice/lib/ctrl/mgr/engine_manager.py +++ b/castervoice/lib/ctrl/mgr/engine_manager.py @@ -1,8 +1,10 @@ -from dragonfly import get_engine, get_current_engine +from dragonfly import get_current_engine from castervoice.lib import printer -if get_engine().name == 'natlink': +try: import natlink +except ImportError: + natlink = None class EngineModesManager(object): @@ -43,7 +45,10 @@ def set_mic_mode(self, mode): if mode in self.mic_modes: self.mic_state = mode if self.engine == 'natlink': - natlink.setMicState(mode) + if natlink is not None: + natlink.setMicState(mode) + else: + printer.out("Caster: natlink is not available on this system") # Overrides DNS/DPI is built in sleep grammar self._exclusive_manager.set_mode(mode, modetype="mic_mode") else: @@ -85,6 +90,9 @@ def set_engine_mode(self, mode=None, state=True): if mode in self.engine_modes: if self.engine == 'natlink': + if natlink is None: + printer.out("Caster: natlink is not available on this system") + return try: natlink.execScript("SetRecognitionMode {}".format( self.engine_modes[mode])) # mode is an integer @@ -117,6 +125,8 @@ def _sync_mode(self): Synchronizes Caster exclusivity modes an with DNS/DPI GUI built-in modes state. """ # TODO: Implement set_engine_mode logic with modes not just mic_state. + if self.engine != 'natlink' or natlink is None: + return caster_mic = self.get_mic_mode() natlink_mic = natlink.getMicState() if caster_mic is None: diff --git a/castervoice/rules/core/text_manipulation_rules/text_manipulation_support.py b/castervoice/rules/core/text_manipulation_rules/text_manipulation_support.py index 14de25ff8..293407a71 100644 --- a/castervoice/rules/core/text_manipulation_rules/text_manipulation_support.py +++ b/castervoice/rules/core/text_manipulation_rules/text_manipulation_support.py @@ -25,21 +25,28 @@ def get_start_end_position(text, phrase, direction, occurrence_number, dictation # def get_start_end_position(text, phrase, direction): if dictation_versus_character == "character": pattern = re.escape(phrase) - if dictation_versus_character == "dictation": + elif dictation_versus_character == "dictation": # avoid e.g. matching 'and' in 'land' but allow e.g. matching 'and' in 'hello.and' # for matching purposes use lowercase # PROBLEM: this will not match words in class names like "Class" in "ClassName" # PROBLEM: it's not matching the right one when you have two occurrences of the same word in a row pattern = r'(?:[^A-Za-z]|\A)({})(?:[^A-Za-z]|\Z)'.format(phrase.lower()) # must get group 1 + else: + print( + "dictation_versus_character must be either 'character' or 'dictation' (got '{}')" + .format(dictation_versus_character) + ) + return - if not re.search(pattern, text.lower()): + lowered_text = text.lower() + if not re.search(pattern, lowered_text): # replaced phase not found print("'{}' not found".format(phrase)) return - match_iter = re.finditer(pattern, text.lower()) + match_iter = re.finditer(pattern, lowered_text) if dictation_versus_character == "character": match_index_list = [(m.start(), m.end()) for m in match_iter] - if dictation_versus_character == "dictation": + else: match_index_list = [(m.start(1), m.end(1)) for m in match_iter] # first group if direction == "left": @@ -48,12 +55,15 @@ def get_start_end_position(text, phrase, direction, occurrence_number, dictation except IndexError: print("There aren't that many occurrences of '{}'".format(phrase)) return - if direction == "right": + elif direction == "right": try: match = match_index_list[occurrence_number - 1] # count from the left except IndexError: print("There aren't that many occurrences of '{}'".format(phrase)) return + else: + print("Direction must be either 'left' or 'right' (got '{}')".format(direction)) + return left_index, right_index = match return (left_index, right_index) @@ -315,6 +325,9 @@ def move_until_phrase(direction, before_after, phrase, number_of_lines_to_search before_after = "after" if direction == "right": before_after = "before" + elif before_after not in ("before", "after"): + print("before_after must be either 'before' or 'after' (got '{}')".format(before_after)) + return selected_text = select_text_and_return_it(direction, number_of_lines_to_search, application) if not selected_text: @@ -329,7 +342,7 @@ def move_until_phrase(direction, before_after, phrase, number_of_lines_to_search return if application == "texstudio": - # Approach 1: Unselect text by pressing left and then right. A little slower but works in Texstudio + # Approach 1: Unselect text by pressing left and then right. A little slower but works in Texstudio Key("left, right").execute() # unselect text if direction == "left": # cursor is at the left side of the previously selected text @@ -337,24 +350,32 @@ def move_until_phrase(direction, before_after, phrase, number_of_lines_to_search selected_text_to_the_left_of_phrase = selected_text[:left_index] multiline_offset_correction = selected_text_to_the_left_of_phrase.count("\r\n") offset = left_index - multiline_offset_correction - if before_after == "after": + elif before_after == "after": selected_text_to_the_left_of_phrase = selected_text[:right_index] multiline_offset_correction = selected_text_to_the_left_of_phrase.count("\r\n") offset = right_index - multiline_offset_correction - Key("right:%d" %offset).execute() + else: + print("before_after must be either 'before' or 'after' (got '{}')".format(before_after)) + return + Key("right:%d" % offset).execute() - if direction == "right": + elif direction == "right": # cursor is at the left side of the previously selected text if before_after == "before": selected_text_to_the_right_of_phrase = selected_text[left_index :] - if before_after == "after": + offset_index = left_index + elif before_after == "after": selected_text_to_the_right_of_phrase = selected_text[right_index :] + offset_index = right_index + else: + print("before_after must be either 'before' or 'after' (got '{}')".format(before_after)) + return multiline_offset_correction = selected_text_to_the_right_of_phrase.count("\r\n") - if before_after == "before": - offset = len(selected_text) - left_index - multiline_offset_correction - if before_after == "after": - offset = len(selected_text) - right_index - multiline_offset_correction - Key("left:%d" %offset).execute() + offset = len(selected_text) - offset_index - multiline_offset_correction + Key("left:%d" % offset).execute() + else: + print("Direction must be either 'left' or 'right' (got '{}')".format(direction)) + return else: # Approach 2: unselect using arrow keys rather than pasting over the existing text. (a little faster) does not work texstudio if right_index < round(len(selected_text))/2: @@ -363,20 +384,26 @@ def move_until_phrase(direction, before_after, phrase, number_of_lines_to_search if before_after == "before": offset_correction = selected_text[: left_index].count("\r\n") offset = left_index - offset_correction - if before_after == "after": + elif before_after == "after": offset_correction = selected_text[: right_index].count("\r\n") offset = right_index - offset_correction - Key("right:%d" %offset).execute() + else: + print("before_after must be either 'before' or 'after' (got '{}')".format(before_after)) + return + Key("right:%d" % offset).execute() else: # it's faster to approach phrase from the right Key("right").execute() # unselect text and place cursor on the right side of selection if before_after == "before": offset_correction = selected_text[left_index :].count("\r\n") offset = len(selected_text) - left_index - offset_correction - if before_after == "after": + elif before_after == "after": offset_correction = selected_text[right_index :].count("\r\n") offset = len(selected_text) - right_index - offset_correction - Key("left:%d" %offset).execute() + else: + print("before_after must be either 'before' or 'after' (got '{}')".format(before_after)) + return + Key("left:%d" % offset).execute() def select_phrase(phrase, direction, number_of_lines_to_search, occurrence_number, dictation_versus_character): @@ -443,6 +470,9 @@ def select_until_phrase(direction, phrase, before_after, number_of_lines_to_sear before_after = "before" if direction == "right": before_after = "after" + elif before_after not in ("before", "after"): + print("before_after must be either 'before' or 'after' (got '{}')".format(before_after)) + return selected_text = select_text_and_return_it(direction, number_of_lines_to_search, application) if not selected_text: @@ -461,32 +491,39 @@ def select_until_phrase(direction, phrase, before_after, number_of_lines_to_sear if application == "texstudio": text_manipulation_paste(selected_text, application) # yes, this is kind of redundant but it gets the proper pause time if direction == "left": - if before_after == "before": - selected_text_to_the_right_of_phrase = selected_text[left_index :] - multiline_offset_correction = selected_text_to_the_right_of_phrase.count("\r\n") - offset = len(selected_text) - left_index - multiline_offset_correction - - if before_after == "after": - selected_text_to_the_right_of_phrase = selected_text[right_index :] - multiline_offset_correction = selected_text_to_the_right_of_phrase.count("\r\n") - offset = len(selected_text) - right_index - multiline_offset_correction - - Key("s-left:%d" %offset).execute() - if direction == "right": + if before_after == "before": + offset_index = left_index + elif before_after == "after": + offset_index = right_index + else: + print("before_after must be either 'before' or 'after' (got '{}')".format(before_after)) + return + + selected_text_to_the_right_of_phrase = selected_text[offset_index :] + multiline_offset_correction = selected_text_to_the_right_of_phrase.count("\r\n") + offset = len(selected_text) - offset_index - multiline_offset_correction + Key("s-left:%d" % offset).execute() + elif direction == "right": multiline_movement_correction = selected_text.count("\r\n") movement_offset = len(selected_text) - multiline_movement_correction if before_after == "before": - multiline_selection_correction = selected_text[: left_index].count("\r\n") - selection_offset = left_index - multiline_movement_correction - if before_after == "after": - multiline_selection_correction = selected_text[: right_index].count("\r\n") - selection_offset = right_index + selection_offset_correction = selected_text[: left_index].count("\r\n") + selection_offset = left_index - selection_offset_correction + elif before_after == "after": + selection_offset_correction = selected_text[: right_index].count("\r\n") + selection_offset = right_index - selection_offset_correction + else: + print("before_after must be either 'before' or 'after' (got '{}')".format(before_after)) + return # move cursor to original position - Key("left:%d" %movement_offset).execute() + Key("left:%d" % movement_offset).execute() # select text - Key("s-right:%d" %selection_offset).execute() + Key("s-right:%d" % selection_offset).execute() + else: + print("Direction must be either 'left' or 'right' (got '{}')".format(direction)) + return # Approach 2: unselect using arrow keys rather than pasting over the existing text. (a little faster) does not work texstudio else: @@ -495,17 +532,25 @@ def select_until_phrase(direction, phrase, before_after, number_of_lines_to_sear if before_after == "before": multiline_correction = selected_text[left_index :].count("\r\n") offset = len(selected_text) - left_index - multiline_correction - if before_after == "after": + elif before_after == "after": multiline_correction = selected_text[right_index :].count("\r\n") offset = len(selected_text) - right_index - multiline_correction - Key("s-left:%d" %offset).execute() - if direction == "right": + else: + print("before_after must be either 'before' or 'after' (got '{}')".format(before_after)) + return + Key("s-left:%d" % offset).execute() + elif direction == "right": Key("left").execute() # unselect text and move to the right side of selection if before_after == "before": multiline_correction = selected_text[: left_index].count("\r\n") offset = left_index - multiline_correction - if before_after == "after": + elif before_after == "after": multiline_correction = selected_text[: right_index].count("\r\n") offset = right_index - multiline_correction - Key("s-right:%d" %offset).execute() - + else: + print("before_after must be either 'before' or 'after' (got '{}')".format(before_after)) + return + Key("s-right:%d" % offset).execute() + else: + print("Direction must be either 'left' or 'right' (got '{}')".format(direction)) + return diff --git a/requirements-mac-linux.txt b/requirements-mac-linux.txt index 0db30fb41..ce5d99e20 100644 --- a/requirements-mac-linux.txt +++ b/requirements-mac-linux.txt @@ -1,4 +1,5 @@ -dragonfly2[kaldi]>=0.34.0 +dragonfly2>=0.34.0; platform_system == "Darwin" +dragonfly2[kaldi]>=0.34.0; platform_system == "Linux" pillow>=8.0.0 tomlkit>=0.11.8 future>=0.18.2 From cc86995c9fda20afe159543ba38f76b746e41e49 Mon Sep 17 00:00:00 2001 From: Kendon Bell Date: Fri, 9 Jan 2026 15:17:00 +1300 Subject: [PATCH 3/3] Update GitHub Actions workflow to use the latest Ubuntu version for Python 3.10 job --- .github/workflows/testrunner.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testrunner.yml b/.github/workflows/testrunner.yml index 5ab95fc8a..9dc002a91 100644 --- a/.github/workflows/testrunner.yml +++ b/.github/workflows/testrunner.yml @@ -31,7 +31,7 @@ jobs: python-linux-3-10-x: name: python 3 linux - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: matrix: python-version: [3.10.x]