Attempt to handle capslock as modifier better.

This commit is contained in:
Storm Dragon
2026-01-09 11:34:37 -05:00
parent eba1ddc419
commit b1b9ffce22
6 changed files with 243 additions and 172 deletions
+5 -141
View File
@@ -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():
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
#
# Copyright (c) 2026 Stormux
# Copyright (c) 2023 Igalia, S.L.
# Author: Joanmarie Diggs <jdiggs@igalia.com>
#
# 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
+6 -30
View File
@@ -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)
+1
View File
@@ -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',
+2
View File
@@ -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
+4 -1
View File
@@ -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)
return Script(app)