Search code examples
linuxpyqtpyqt6pynputcapslock

Caps Lock key always on when pressed in Linux


Since the keyboard indicator widget cannot be run on my kali system, I decided to write one myself using pyqt. I found that it would be normal if I separated the program and ran it, but not with pyqt6. It runs normally on Windows, but a very strange problem occurs on Linux. Even if I keep pressing caps lock repeatedly, it still returns the same wrong value.

import subprocess
from time import sleep
while(True):
    print(subprocess.run("xset q | grep \"Caps Lock\" | awk -F': ' '{gsub(/[0-9]/,\"\",$3); print $3}'",
                                        stdout=subprocess.PIPE,
                                        shell=True,
                                        text=True).stdout.strip() == 'on')    
    sleep(0.3)
# pip install PyQt6 pynput
from platform import system
from sys import argv, exit

from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPalette, QColor, QFont
from PyQt6.QtWidgets import QApplication, QMainWindow, QLabel
from pynput import keyboard


class CapsLockDetector(QMainWindow):
    def __init__(self):
        super().__init__()

        self.status_label = None
        self.initUI()
        self.setupKeyboardHook()

    def initUI(self):
        self.setWindowTitle('Caps Lock Detector')
        self.setWindowFlags(Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.FramelessWindowHint)
        self.setGeometry(0, 0, 400, 120)

        palette = self.palette()
        palette.setColor(QPalette.ColorRole.Window, QColor(10, 10, 10))
        self.setPalette(palette)

        screen_geometry = QApplication.primaryScreen().geometry()
        self.move(screen_geometry.x(), screen_geometry.y())
        self.status_label = QLabel(self)
        self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.setCentralWidget(self.status_label)
        self.status_label.setStyleSheet("color: white;")
        font = QFont("Consolas", 40)  
        self.status_label.setFont(font)
        self.updateCapsLockStatus()

    def setupKeyboardHook(self):
        listener = keyboard.Listener(on_press=self.on_key_press)
        listener.start()

    def on_key_press(self, key):
        if key == keyboard.Key.caps_lock:
            self.updateCapsLockStatus()

    def updateCapsLockStatus(self):
        new_status: bool = None
        if system() == "Windows":
            import ctypes
            hllDll = ctypes.WinDLL("User32.dll")
            VK_CAPITAL = 0x14
            new_status = hllDll.GetKeyState(VK_CAPITAL) not in [0, 65408]
        elif system() == "Linux":
            import subprocess
            new_status = subprocess.run("xset q | grep \"Caps Lock\" | awk -F': ' '{gsub(/[0-9]/,\"\",$3); print $3}'",
                                        stdout=subprocess.PIPE,
                                        shell=True,
                                        text=True).stdout.strip() == 'on'
            
            print(new_status)
        self.show()
        self.status_label.setText("OFF" if not new_status else "ON")

    def mousePressEvent(self, event):
        self.hide()


if __name__ == '__main__':
    app = QApplication(argv)
    window = CapsLockDetector()
    window.show()
    exit(app.exec())

I wanna my program returns the correct value


Solution

  • On Linux (with X11/Xorg), the caps lock is switched off only when the key is released. In fact, there is a related report that also includes a patch, but, unfortunately, it was never merged even after ten years!

    So, the safest solution is to rely on the key release only:

        def setupKeyboardHook(self):
            listener = keyboard.Listener(on_release=self.on_key_release)
            listener.start()
    
        def on_key_release(self, key):
            if key == keyboard.Key.caps_lock:
                self.updateCapsLockStatus()
    

    Now, since this is intended for, possibly, a long running program and you may still want to see the current status updated as soon as possible, you could keep an internal status and only update it when necessary.

    But, before that, there is an extremely important aspect that you need to keep in mind, even for your original implementation: pynput works by using a separate thread, while widgets are not thread-safe.

    Note that when something is "not thread-safe", it doesn't necessarily mean that it won't work, but it's not safe. Trying to access UI elements from a separate thread may work fine in some situations, but often results in unexpected results, graphical issues, inconsistent behavior or even fatal crash. That's why it's always discouraged, and QThread with proper signals is the only safe way to make threads communicate with the UI.

    The proper way to use a keyboard listener like this is to move it to its own thread.

    class Listener(QThread):
        caps = pyqtSignal(bool)
        def __init__(self):
            super().__init__()
            self.listener = keyboard.Listener(
                on_press=self.press, on_release=self.release)
            self.started.connect(self.listener.start)
    
        def press(self, key):
            if key == keyboard.Key.caps_lock:
                self.caps.emit(True)
    
        def release(self, key):
            if key == keyboard.Key.caps_lock:
                self.caps.emit(False)
    
    
    class CapsLockDetector(QMainWindow):
        capsStatus = False
        def __init__(self):
            ...
            self.listener = Listener()
            self.listener.caps.connect(self.handleCaps)
            self.listener.start()
    
        def handleCaps(self, pressed):
            if not pressed or not self.capsStatus:
                self.updateCapsLockStatus()
    
        def updateCapsLockStatus(self):
            ...
            self.capsStatus = new_status
    

    Note that using a QMainWindow for this doesn't make a lot of sense, and you could just directly subclass QLabel.