qs: add capslock monitor

This commit is contained in:
2026-04-20 18:55:59 +02:00
parent 266ea92f4a
commit 454262a803
7 changed files with 260 additions and 33 deletions
+8 -17
View File
@@ -1,26 +1,17 @@
[Settings] [Settings]
gtk-application-prefer-dark-theme=true gtk-theme-name=catppuccin-mocha-blue-standard+default
gtk-button-images=true gtk-icon-theme-name=Papirus
gtk-cursor-blink=true gtk-font-name=Sarasa UI SC 10
gtk-cursor-blink-time=1000
gtk-cursor-theme-name=Bibata-Modern-Ice gtk-cursor-theme-name=Bibata-Modern-Ice
gtk-cursor-theme-size=24 gtk-cursor-theme-size=24
gtk-decoration-layout=icon:minimize,maximize,close gtk-toolbar-style=GTK_TOOLBAR_ICONS
gtk-enable-animations=true gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR
gtk-button-images=0
gtk-menu-images=0
gtk-enable-event-sounds=1 gtk-enable-event-sounds=1
gtk-enable-input-feedback-sounds=0 gtk-enable-input-feedback-sounds=0
gtk-font-name=Sarasa UI SC, 10
gtk-icon-theme-name=Papirus
gtk-menu-images=true
gtk-modules=colorreload-gtk-module:appmenu-gtk-module
gtk-primary-button-warps-slider=true
gtk-shell-shows-menubar=1
gtk-sound-theme-name=ocean
gtk-theme-name=catppuccin-mocha-blue-standard+default
gtk-toolbar-icon-size=GTK_ICON_SIZE_LARGE_TOOLBAR
gtk-toolbar-style=3
gtk-xft-antialias=1 gtk-xft-antialias=1
gtk-xft-dpi=122880
gtk-xft-hinting=1 gtk-xft-hinting=1
gtk-xft-hintstyle=hintslight gtk-xft-hintstyle=hintslight
gtk-xft-rgba=rgb gtk-xft-rgba=rgb
gtk-application-prefer-dark-theme=1
+4 -11
View File
@@ -1,14 +1,7 @@
[Settings] [Settings]
gtk-application-prefer-dark-theme=true gtk-theme-name=catppuccin-mocha-blue-standard+default
gtk-cursor-blink=true gtk-icon-theme-name=Papirus
gtk-cursor-blink-time=1000 gtk-font-name=Sarasa UI SC 10
gtk-cursor-theme-name=Bibata-Modern-Ice gtk-cursor-theme-name=Bibata-Modern-Ice
gtk-cursor-theme-size=24 gtk-cursor-theme-size=24
gtk-decoration-layout=icon:minimize,maximize,close gtk-application-prefer-dark-theme=1
gtk-enable-animations=true
gtk-font-name=Sarasa UI SC, 10
gtk-icon-theme-name=Papirus
gtk-primary-button-warps-slider=true
gtk-sound-theme-name=ocean
gtk-theme-name=catppuccin-mocha-blue-standard+default
gtk-xft-dpi=122880
@@ -634,6 +634,28 @@ Singleton {
Pipewire.preferredDefaultAudioSource = newSource; Pipewire.preferredDefaultAudioSource = newSource;
} }
onVolumeChanged: {
if (root.consumeOutputOSDSuppression()) {
return;
}
if (root.muted) {
TempNotificationService.showWithIcon("volume-mute", "Muted");
return;
}
TempNotificationService.showWithIcon(root.getOutputIcon(), Math.round(root.volume * 100) + "%");
}
onInputVolumeChanged: {
if (root.consumeInputOSDSuppression()) {
return;
}
if (root.inputMuted) {
TempNotificationService.showWithIcon("volume-mute", "Muted");
return;
}
TempNotificationService.showWithIcon(root.getInputIcon(), Math.round(root.inputVolume * 100) + "%");
}
onMutedChanged: { onMutedChanged: {
if (root.muted) { if (root.muted) {
TempNotificationService.showWithIcon("volume-mute", "Muted"); TempNotificationService.showWithIcon("volume-mute", "Muted");
@@ -0,0 +1,35 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Services
import qs.Utils
pragma Singleton
Singleton {
id: root
property bool isCapslockOn: false
onIsCapslockOnChanged: {
TempNotificationService.showWithIcon("letter-case", "Caps Lock " + (root.isCapslockOn ? "ON" : "OFF"));
}
Process {
id: capslockMonitorProcess
running: true
command: ["led_monitor", "-l", "capslock"]
stdout: SplitParser {
splitMarker: "\n"
onRead: (line) => {
if (line.trim() === "1")
root.isCapslockOn = true;
else if (line.trim() === "0")
root.isCapslockOn = false;
}
}
}
}
@@ -11,7 +11,7 @@ Singleton {
property bool dirsLoaded: false property bool dirsLoaded: false
property bool initialized: dirsLoaded && ImageCacheService.initialized && ShellState.isLoaded && SettingsService.isLoaded property bool initialized: dirsLoaded && ImageCacheService.initialized && ShellState.isLoaded && SettingsService.isLoaded
Component.onCompleted: { function mkdirs() {
let mkdirs = ""; let mkdirs = "";
for (const dir of [Paths.cacheDir, Paths.configDir, Paths.recordingDir, Paths.notesDir]) { for (const dir of [Paths.cacheDir, Paths.configDir, Paths.recordingDir, Paths.notesDir]) {
mkdirs += `mkdir -p "${dir}" && `; mkdirs += `mkdir -p "${dir}" && `;
@@ -22,6 +22,11 @@ Singleton {
process.running = true; process.running = true;
} }
Component.onCompleted: {
ImageCacheService.init();
root.mkdirs();
}
Process { Process {
id: process id: process
@@ -10,10 +10,6 @@ import qs.Services
ShellRoot { ShellRoot {
id: root id: root
Component.onCompleted: {
ImageCacheService.init();
}
Loader { Loader {
id: loader id: loader
@@ -24,6 +20,7 @@ ShellRoot {
SunsetService; SunsetService;
NotesService; NotesService;
WallpaperCycle; WallpaperCycle;
CapslockService;
} }
IPCService { IPCService {
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
import sys
import glob
import argparse
import select
import os
import fcntl
import struct
# struct input_event: struct timeval (typically 2 longs), type (uint16), code (uint16), value (int32)
# '@' ensures native byte order, size, and alignment.
EVENT_FORMAT = '@llHHi'
EVENT_SIZE = struct.calcsize(EVENT_FORMAT)
EV_LED = 0x11
LED_CODES = {
'numlock': 0x00,
'capslock': 0x01,
'scrolllock': 0x02
}
def get_led_state(led_type: str) -> int:
pattern = f"/sys/class/leds/*::{led_type}/brightness"
paths = glob.glob(pattern)
if not paths:
return 0
for path in paths:
try:
with open(path, 'r') as f:
if int(f.read().strip()) > 0:
return 1
except (IOError, ValueError, OSError):
continue
return 0
def has_led_capability(event_path: str) -> bool:
try:
basename = os.path.basename(event_path)
cap_path = f"/sys/class/input/{basename}/device/capabilities/led"
if os.path.exists(cap_path):
with open(cap_path, 'r') as f:
return f.read().strip() != "0"
except OSError:
pass
return False
class DeviceMonitor:
def __init__(self, target_led: str):
self.target_led = target_led
self.target_led_code = LED_CODES[target_led]
self.poller = select.poll()
self.active_fds = {}
self.last_state = get_led_state(self.target_led)
self.emit_state(self.last_state)
def emit_state(self, state: int) -> None:
sys.stdout.write(f"{state}\n")
sys.stdout.flush()
def scan_devices(self) -> None:
current_fds = set(self.active_fds.keys())
paths = glob.glob('/dev/input/event*')
found_fds = set()
for path in paths:
if not has_led_capability(path):
continue
already_open = False
for fd, opened_path in self.active_fds.items():
if opened_path == path:
found_fds.add(fd)
already_open = True
break
if not already_open:
try:
# Pure unbuffered OS file descriptor
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
self.poller.register(fd, select.POLLIN)
self.active_fds[fd] = path
found_fds.add(fd)
except PermissionError:
sys.stderr.write(f"Error: Permission denied when opening {path}.\n")
sys.exit(1)
except OSError:
continue
for fd in current_fds - found_fds:
self.remove_device(fd)
def remove_device(self, fd: int) -> None:
if fd in self.active_fds:
try:
self.poller.unregister(fd)
os.close(fd)
del self.active_fds[fd]
except Exception:
pass
def run(self) -> None:
self.scan_devices()
try:
while True:
events = self.poller.poll(1000)
if not events:
self.scan_devices()
current_state = get_led_state(self.target_led)
if current_state != self.last_state:
self.emit_state(current_state)
self.last_state = current_state
continue
fds_to_remove = []
# Flag to check if we intercepted an EV_LED during this batch
led_event_triggered = False
for fd, event in events:
if event & select.POLLIN:
try:
while True:
# Drain heavily with pure OS call
data = os.read(fd, 4096)
if not data:
fds_to_remove.append(fd)
break
# Process the chunk if we want direct parsing
events_count = len(data) // EVENT_SIZE
for i in range(events_count):
chunk = data[i * EVENT_SIZE : (i + 1) * EVENT_SIZE]
_, _, ev_type, ev_code, _ = struct.unpack(EVENT_FORMAT, chunk)
if ev_type == EV_LED and ev_code == self.target_led_code:
# Instead of acting immediately on the value, we just flag it.
# This prevents state bouncing between multiple kernel devices.
led_event_triggered = True
except BlockingIOError:
pass
except OSError:
fds_to_remove.append(fd)
for fd in fds_to_remove:
self.remove_device(fd)
# If ANY EV_LED was detected during the drain, fall back to sysfs ONCE
# at the end of the batch. This solves the bouncing/dropping issue perfectly.
if led_event_triggered:
current_state = get_led_state(self.target_led)
if current_state != self.last_state:
self.emit_state(current_state)
self.last_state = current_state
except KeyboardInterrupt:
sys.exit(0)
finally:
for fd in list(self.active_fds.keys()):
try:
os.close(fd)
except Exception:
pass
def main():
parser = argparse.ArgumentParser(description="Keyboard LED monitor.")
parser.add_argument(
'-l', '--led',
type=str,
default='capslock',
choices=['capslock', 'numlock', 'scrolllock'],
help="Target LED to monitor"
)
args = parser.parse_args()
monitor = DeviceMonitor(args.led)
monitor.run()
if __name__ == "__main__":
main()