From b1b9ffce2239012ac33800318ef2492efa387622 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Fri, 9 Jan 2026 11:34:37 -0500 Subject: [PATCH] Attempt to handle capslock as modifier better. --- src/cthulhu/cthulhu.py | 146 +-------------- src/cthulhu/cthulhu_modifier_manager.py | 225 ++++++++++++++++++++++++ src/cthulhu/input_event.py | 36 +--- src/cthulhu/meson.build | 1 + src/cthulhu/scripts/default.py | 2 + src/cthulhu/scripts/sleepmode/script.py | 5 +- 6 files changed, 243 insertions(+), 172 deletions(-) create mode 100644 src/cthulhu/cthulhu_modifier_manager.py diff --git a/src/cthulhu/cthulhu.py b/src/cthulhu/cthulhu.py index 344df60..d81477d 100644 --- a/src/cthulhu/cthulhu.py +++ b/src/cthulhu/cthulhu.py @@ -186,9 +186,7 @@ class APIHelper: import gi import importlib import os -import re import signal -import subprocess import sys gi.require_version("Atspi", "2.0") @@ -212,6 +210,7 @@ from . import logger from . import messages from . import notification_presenter from . import focus_manager +from . import cthulhu_modifier_manager from . import cthulhu_state from . import cthulhu_platform from . import script_manager @@ -280,14 +279,6 @@ EXIT_CODE_HANG = 50 # _userSettings = None -# A subset of the original Xmodmap info prior to our stomping on it. -# Right now, this is just for the user's chosen Cthulhu modifier(s). -# -_originalXmodmap = "" -_cthulhuModifiers = settings.DESKTOP_MODIFIER_KEYS + settings.LAPTOP_MODIFIER_KEYS -_capsLockCleared = False -_restoreCthulhuKeys = False - ######################################################################## # # # METHODS TO HANDLE APPLICATION LIST AND FOCUSED OBJECTS # @@ -382,125 +373,7 @@ def deviceChangeHandler(deviceManager, device): if source == Gdk.InputSource.KEYBOARD: msg = "CTHULHU: Keyboard change detected, re-creating the xmodmap" debug.printMessage(debug.LEVEL_INFO, msg, True) - _createCthulhuXmodmap() - -def updateKeyMap(keyboardEvent): - """Unsupported convenience method to call sad hacks which should go away.""" - - global _restoreCthulhuKeys - if keyboardEvent.is_pressed_key(): - return - - if keyboardEvent.event_string in settings.cthulhuModifierKeys \ - and cthulhu_state.bypassNextCommand: - _restoreXmodmap() - _restoreCthulhuKeys = True - return - - if _restoreCthulhuKeys and not cthulhu_state.bypassNextCommand: - _createCthulhuXmodmap() - _restoreCthulhuKeys = False - -def _setXmodmap(xkbmap): - """Set the keyboard map using xkbcomp.""" - p = subprocess.Popen(['xkbcomp', '-w0', '-', os.environ['DISPLAY']], - stdin=subprocess.PIPE, stdout=None, stderr=None) - p.communicate(xkbmap) - -def _setCapsLockAsCthulhuModifier(enable): - """Enable or disable use of the caps lock key as an Cthulhu modifier key.""" - interpretCapsLineProg = re.compile( - r'^\s*interpret\s+Caps[_+]Lock[_+]AnyOfOrNone\s*\(all\)\s*{\s*$', re.I) - normalCapsLineProg = re.compile( - r'^\s*action\s*=\s*LockMods\s*\(\s*modifiers\s*=\s*Lock\s*\)\s*;\s*$', re.I) - interpretShiftLineProg = re.compile( - r'^\s*interpret\s+Shift[_+]Lock[_+]AnyOf\s*\(\s*Shift\s*\+\s*Lock\s*\)\s*{\s*$', re.I) - normalShiftLineProg = re.compile( - r'^\s*action\s*=\s*LockMods\s*\(\s*modifiers\s*=\s*Shift\s*\)\s*;\s*$', re.I) - disabledModLineProg = re.compile( - r'^\s*action\s*=\s*NoAction\s*\(\s*\)\s*;\s*$', re.I) - normalCapsLine = ' action= LockMods(modifiers=Lock);' - normalShiftLine = ' action= LockMods(modifiers=Shift);' - disabledModLine = ' action= NoAction();' - lines = _originalXmodmap.decode('UTF-8').split('\n') - foundCapsInterpretSection = False - foundShiftInterpretSection = False - modified = False - for i, line in enumerate(lines): - if not foundCapsInterpretSection and not foundShiftInterpretSection: - if interpretCapsLineProg.match(line): - foundCapsInterpretSection = True - elif interpretShiftLineProg.match(line): - foundShiftInterpretSection = True - elif foundCapsInterpretSection: - if enable: - if normalCapsLineProg.match(line): - lines[i] = disabledModLine - modified = True - else: - if disabledModLineProg.match(line): - lines[i] = normalCapsLine - modified = True - if line.find('}'): - foundCapsInterpretSection = False - else: # foundShiftInterpretSection - if enable: - if normalShiftLineProg.match(line): - lines[i] = disabledModLine - modified = True - else: - if disabledModLineProg.match(line): - lines[i] = normalShiftLine - modified = True - if line.find('}'): - foundShiftInterpretSection = False - if modified: - _setXmodmap(bytes('\n'.join(lines), 'UTF-8')) - -def _createCthulhuXmodmap(): - """Makes an Cthulhu-specific Xmodmap so that the keys behave as we - need them to do. This is especially the case for the Cthulhu modifier. - """ - - global _capsLockCleared - - if "Caps_Lock" in settings.cthulhuModifierKeys \ - or "Shift_Lock" in settings.cthulhuModifierKeys: - _setCapsLockAsCthulhuModifier(True) - _capsLockCleared = True - elif _capsLockCleared: - _setCapsLockAsCthulhuModifier(False) - _capsLockCleared = False - -def _storeXmodmap(keyList): - """Save the original xmodmap for the keys in keyList before we alter it. - - Arguments: - - keyList: A list of named keys to look for. - """ - - global _originalXmodmap - _originalXmodmap = subprocess.check_output(['xkbcomp', os.environ['DISPLAY'], '-']) - -def _restoreXmodmap(keyList=[]): - """Restore the original xmodmap values for the keys in keyList. - - Arguments: - - keyList: A list of named keys to look for. An empty list means - to restore the entire saved xmodmap. - """ - - msg = "CTHULHU: Attempting to restore original xmodmap" - debug.printMessage(debug.LEVEL_INFO, msg, True) - - global _capsLockCleared - _capsLockCleared = False - p = subprocess.Popen(['xkbcomp', '-w0', '-', os.environ['DISPLAY']], - stdin=subprocess.PIPE, stdout=None, stderr=None) - p.communicate(_originalXmodmap) - - msg = "CTHULHU: Original xmodmap restored" - debug.printMessage(debug.LEVEL_INFO, msg, True) + cthulhu_modifier_manager.getManager().refreshCthulhuModifiers("Keyboard change detected.") def setKeyHandling(new): """Toggle use of the new vs. legacy key handling mode. @@ -588,16 +461,7 @@ def loadUserSettings(script=None, inputEvent=None, skipReloadMessage=False): if _settingsManager.getSetting('enableSound'): player.init() - global _cthulhuModifiers - custom = [k for k in settings.cthulhuModifierKeys if k not in _cthulhuModifiers] - _cthulhuModifiers += custom - # Handle the case where a change was made in the Cthulhu Preferences dialog. - # - if _originalXmodmap: - _restoreXmodmap(_cthulhuModifiers) - - _storeXmodmap(_cthulhuModifiers) - _createCthulhuXmodmap() + cthulhu_modifier_manager.getManager().refreshCthulhuModifiers("Loading user settings.") # Activate core systems FIRST before loading plugins _scriptManager.activate() @@ -894,7 +758,7 @@ def shutdown(script=None, inputEvent=None): signal.alarm(0) _initialized = False - _restoreXmodmap(_cthulhuModifiers) + cthulhu_modifier_manager.getManager().unsetCthulhuModifiers("Shutting down.") debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: Quitting Atspi main event loop', True) Atspi.event_quit() @@ -948,7 +812,7 @@ def crashOnSignal(signum, frame): msg = f"CTHULHU: Shutting down and exiting due to signal={signum} {signalString}" debug.printMessage(debug.LEVEL_SEVERE, msg, True) debug.printStack(debug.LEVEL_SEVERE) - _restoreXmodmap(_cthulhuModifiers) + cthulhu_modifier_manager.getManager().unsetCthulhuModifiers("Crashed") sys.exit(1) def main(): diff --git a/src/cthulhu/cthulhu_modifier_manager.py b/src/cthulhu/cthulhu_modifier_manager.py new file mode 100644 index 0000000..6ef08f2 --- /dev/null +++ b/src/cthulhu/cthulhu_modifier_manager.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2026 Stormux +# Copyright (c) 2023 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. +# +# Forked from Orca screen reader. +# Cthulhu project: https://git.stormux.org/storm/cthulhu + +"""Manages the Cthulhu modifier key(s).""" + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2026 Stormux" +__license__ = "LGPL" + +import os +import re +import subprocess + +import gi +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi +from gi.repository import GLib + +import cthulhu.debug as debug +import cthulhu.settings as settings + +class CthulhuModifierManager: + """Manages the Cthulhu modifier.""" + + def __init__(self): + self._originalXmodmap = b"" + self._capsLockCleared = False + + def refreshCthulhuModifiers(self, reason=""): + """Refreshes the Cthulhu modifier keys.""" + + msg = "CTHULHU MODIFIER MANAGER: Refreshing Cthulhu modifiers" + if reason: + msg += f": {reason}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + + display = os.environ.get("DISPLAY") + if not display: + msg = "CTHULHU MODIFIER MANAGER: DISPLAY not set, skipping xkbcomp operations" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return + + self.unsetCthulhuModifiers(reason) + with subprocess.Popen(["xkbcomp", display, "-"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) as process: + self._originalXmodmap, _ = process.communicate() + self._createCthulhuXmodmap() + + def unsetCthulhuModifiers(self, reason=""): + """Turns the Cthulhu modifiers back into their original purpose.""" + + msg = "CTHULHU MODIFIER MANAGER: Attempting to restore original xmodmap" + if reason: + msg += f": {reason}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + + if not self._originalXmodmap: + msg = "CTHULHU MODIFIER MANAGER: No stored xmodmap found" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return + + display = os.environ.get("DISPLAY") + if not display: + msg = "CTHULHU MODIFIER MANAGER: DISPLAY not set, skipping xmodmap restoration" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return + + self._capsLockCleared = False + with subprocess.Popen(["xkbcomp", "-w0", "-", display], + stdin=subprocess.PIPE, stdout=None, stderr=None) as process: + process.communicate(self._originalXmodmap) + + msg = "CTHULHU MODIFIER MANAGER: Original xmodmap restored" + debug.printMessage(debug.LEVEL_INFO, msg, True) + + def _createCthulhuXmodmap(self): + """Makes a Cthulhu-specific Xmodmap so that the modifier works.""" + + msg = "CTHULHU MODIFIER MANAGER: Creating Cthulhu xmodmap" + debug.printMessage(debug.LEVEL_INFO, msg, True) + + if "Caps_Lock" in settings.cthulhuModifierKeys \ + or "Shift_Lock" in settings.cthulhuModifierKeys: + self.setCapsLockAsCthulhuModifier(True) + self._capsLockCleared = True + elif self._capsLockCleared: + self.setCapsLockAsCthulhuModifier(False) + self._capsLockCleared = False + + def setCapsLockAsCthulhuModifier(self, enable): + """Enable or disable use of the caps lock key as a Cthulhu modifier key.""" + + msg = "CTHULHU MODIFIER MANAGER: Setting caps lock as the Cthulhu modifier" + debug.printMessage(debug.LEVEL_INFO, msg, True) + + display = os.environ.get("DISPLAY") + if not display: + msg = "CTHULHU MODIFIER MANAGER: DISPLAY not set, cannot modify caps lock" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return + + if not self._originalXmodmap: + msg = "CTHULHU MODIFIER MANAGER: No xmodmap available, cannot modify caps lock" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return + + interpretCapsLineProg = re.compile( + r'^\s*interpret\s+Caps[_+]Lock[_+]AnyOfOrNone\s*\(all\)\s*{\s*$', re.I) + normalCapsLineProg = re.compile( + r'^\s*action\s*=\s*LockMods\s*\(\s*modifiers\s*=\s*Lock\s*\)\s*;\s*$', re.I) + interpretShiftLineProg = re.compile( + r'^\s*interpret\s+Shift[_+]Lock[_+]AnyOf\s*\(\s*Shift\s*\+\s*Lock\s*\)\s*{\s*$', re.I) + normalShiftLineProg = re.compile( + r'^\s*action\s*=\s*LockMods\s*\(\s*modifiers\s*=\s*Shift\s*\)\s*;\s*$', re.I) + disabledModLineProg = re.compile( + r'^\s*action\s*=\s*NoAction\s*\(\s*\)\s*;\s*$', re.I) + normalCapsLine = ' action= LockMods(modifiers=Lock);' + normalShiftLine = ' action= LockMods(modifiers=Shift);' + disabledModLine = ' action= NoAction();' + lines = self._originalXmodmap.decode('UTF-8').split('\n') + foundCapsInterpretSection = False + foundShiftInterpretSection = False + modified = False + for i, line in enumerate(lines): + if not foundCapsInterpretSection and not foundShiftInterpretSection: + if interpretCapsLineProg.match(line): + foundCapsInterpretSection = True + elif interpretShiftLineProg.match(line): + foundShiftInterpretSection = True + elif foundCapsInterpretSection: + if enable: + if normalCapsLineProg.match(line): + lines[i] = disabledModLine + modified = True + else: + if disabledModLineProg.match(line): + lines[i] = normalCapsLine + modified = True + if line.find('}'): + foundCapsInterpretSection = False + elif foundShiftInterpretSection: + if enable: + if normalShiftLineProg.match(line): + lines[i] = disabledModLine + modified = True + else: + if disabledModLineProg.match(line): + lines[i] = normalShiftLine + modified = True + if line.find('}'): + foundShiftInterpretSection = False + if modified: + msg = "CTHULHU MODIFIER MANAGER: Updating xmodmap" + debug.printMessage(debug.LEVEL_INFO, msg, True) + + with subprocess.Popen(["xkbcomp", "-w0", "-", display], + stdin=subprocess.PIPE, stdout=None, stderr=None) as process: + process.communicate(bytes('\n'.join(lines), 'UTF-8')) + else: + msg = "CTHULHU MODIFIER MANAGER: Not updating xmodmap" + debug.printMessage(debug.LEVEL_INFO, msg, True) + + def toggleModifier(self, keyboardEvent): + """Toggles the modifier to enable double-clicking causing normal behavior.""" + + if keyboardEvent.keyval_name in ["Caps_Lock", "Shift_Lock"]: + self._toggleModifierLock(keyboardEvent) + return + + def _toggleModifierLock(self, keyboardEvent): + """Toggles the lock for a modifier to enable double-clicking causing normal behavior.""" + + if not keyboardEvent.is_pressed_key(): + return + + def toggle(modifiers, modifier): + if modifiers & modifier: + lock = Atspi.KeySynthType.UNLOCKMODIFIERS + msg = "CTHULHU MODIFIER MANAGER: Unlocking CapsLock" + debug.printMessage(debug.LEVEL_INFO, msg, True) + else: + lock = Atspi.KeySynthType.LOCKMODIFIERS + msg = "CTHULHU MODIFIER MANAGER: Locking CapsLock" + debug.printMessage(debug.LEVEL_INFO, msg, True) + Atspi.generate_keyboard_event(modifier, "", lock) + + if keyboardEvent.keyval_name == "Caps_Lock": + modifier = 1 << Atspi.ModifierType.SHIFTLOCK + elif keyboardEvent.keyval_name == "Shift_Lock": + modifier = 1 << Atspi.ModifierType.SHIFT + else: + return + + msg = "CTHULHU MODIFIER MANAGER: Scheduling lock change" + debug.printMessage(debug.LEVEL_INFO, msg, True) + GLib.timeout_add(1, toggle, keyboardEvent.modifiers, modifier) + +_manager = CthulhuModifierManager() + +def getManager(): + """Returns the CthulhuModifierManager singleton.""" + return _manager diff --git a/src/cthulhu/input_event.py b/src/cthulhu/input_event.py index 4da6fb8..ece3cdf 100644 --- a/src/cthulhu/input_event.py +++ b/src/cthulhu/input_event.py @@ -47,6 +47,7 @@ from . import keybindings from . import keynames from . import messages from . import cthulhu +from . import cthulhu_modifier_manager from . import cthulhu_state from . import script_manager from . import settings @@ -347,7 +348,10 @@ class KeyboardEvent(InputEvent): KeyboardEvent.secondCthulhuModifierTime < KeyboardEvent.lastCthulhuModifierAloneTime + 0.5): # double-cthulhu, let the real action happen - self._bypassCthulhu = True + if self.event_string in ["Caps_Lock", "Shift_Lock"]: + cthulhu_modifier_manager.getManager().toggleModifier(self) + else: + self._bypassCthulhu = True if not _isPressed: KeyboardEvent.lastCthulhuModifierAlone = False KeyboardEvent.lastCthulhuModifierAloneTime = False @@ -955,7 +959,6 @@ class KeyboardEvent(InputEvent): if (self.event_string == "Caps_Lock" \ or self.event_string == "Shift_Lock") \ and self.type == Atspi.EventType.KEY_PRESSED_EVENT: - self._lock_mod() self.keyType = KeyboardEvent.TYPE_LOCKING self._present() return False, 'Bypassed cthulhu modifier' @@ -984,6 +987,7 @@ class KeyboardEvent(InputEvent): if cthulhu_state.bypassNextCommand: if not self.is_modifier_key(): cthulhu_state.bypassNextCommand = False + cthulhu_modifier_manager.getManager().refreshCthulhuModifiers("Bypass next command disabled") self._script.addKeyGrabs() return False, 'Bypass next command' @@ -999,34 +1003,6 @@ class KeyboardEvent(InputEvent): return False, 'Unaddressed case' - def _lock_mod(self): - def lock_mod(modifiers, modifier): - def lockit(): - try: - if modifiers & modifier: - lock = Atspi.KeySynthType.UNLOCKMODIFIERS - debug.printMessage(debug.LEVEL_INFO, "Unlocking capslock", True) - else: - lock = Atspi.KeySynthType.LOCKMODIFIERS - debug.printMessage(debug.LEVEL_INFO, "Locking capslock", True) - Atspi.generate_keyboard_event(modifier, "", lock) - debug.printMessage(debug.LEVEL_INFO, "Done with capslock", True) - except Exception: - debug.printMessage(debug.LEVEL_INFO, "Could not trigger capslock, " \ - "at-spi2-core >= 2.32 is needed for triggering capslock", True) - pass - return lockit - if self.event_string == "Caps_Lock": - modifier = 1 << Atspi.ModifierType.SHIFTLOCK - elif self.event_string == "Shift_Lock": - modifier = 1 << Atspi.ModifierType.SHIFT - else: - tokens = ["Unknown locking key", self.event_string] - debug.printTokens(debug.LEVEL_WARNING, tokens, True) - return - debug.printMessage(debug.LEVEL_INFO, "Scheduling capslock", True) - GLib.timeout_add(1, lock_mod(self.modifiers, modifier)) - def _consume(self): startTime = time.time() data = "'%s' (%d)" % (self.event_string, self.hw_code) diff --git a/src/cthulhu/meson.build b/src/cthulhu/meson.build index f7ed6a1..9b15cf9 100644 --- a/src/cthulhu/meson.build +++ b/src/cthulhu/meson.build @@ -34,6 +34,7 @@ cthulhu_python_sources = files([ 'colornames.py', 'common_keyboardmap.py', 'cthulhuVersion.py', + 'cthulhu_modifier_manager.py', 'cthulhu_state.py', 'date_and_time_presenter.py', 'dbus_service.py', diff --git a/src/cthulhu/scripts/default.py b/src/cthulhu/scripts/default.py index 264e751..03e7c17 100644 --- a/src/cthulhu/scripts/default.py +++ b/src/cthulhu/scripts/default.py @@ -55,6 +55,7 @@ import cthulhu.input_event_manager as input_event_manager import cthulhu.keybindings as keybindings import cthulhu.messages as messages import cthulhu.cthulhu as cthulhu +import cthulhu.cthulhu_modifier_manager as cthulhu_modifier_manager import cthulhu.cthulhu_state as cthulhu_state import cthulhu.phonnames as phonnames import cthulhu.script as script @@ -834,6 +835,7 @@ class Script(script.Script): self.presentMessage(messages.BYPASS_MODE_ENABLED) cthulhu_state.bypassNextCommand = True + cthulhu_modifier_manager.getManager().unsetCthulhuModifiers("Bypass next command enabled") self.removeKeyGrabs() return True diff --git a/src/cthulhu/scripts/sleepmode/script.py b/src/cthulhu/scripts/sleepmode/script.py index dfb2271..11104f6 100644 --- a/src/cthulhu/scripts/sleepmode/script.py +++ b/src/cthulhu/scripts/sleepmode/script.py @@ -37,6 +37,7 @@ __license__ = "LGPL" import cthulhu.scripts.default as default import cthulhu.debug as debug +import cthulhu.cthulhu_modifier_manager as cthulhu_modifier_manager import cthulhu.sleep_mode_manager as sleep_mode_manager class Script(default.Script): @@ -55,6 +56,7 @@ class Script(default.Script): debug.printMessage(debug.LEVEL_INFO, "SLEEP MODE SCRIPT: Activating", True) super().activate() + cthulhu_modifier_manager.getManager().unsetCthulhuModifiers("Entering sleep mode.") # Get the manager and add its bindings and handlers manager = sleep_mode_manager.getManager() @@ -76,6 +78,7 @@ class Script(default.Script): # Restore key grabs self.addKeyGrabs() + cthulhu_modifier_manager.getManager().refreshCthulhuModifiers("Exiting sleep mode.") super().deactivate() @@ -153,4 +156,4 @@ class Script(default.Script): def get_script(app): """Returns the script for the given application.""" - return Script(app) \ No newline at end of file + return Script(app)