qs: add capslock monitor
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Executable
+184
@@ -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()
|
||||||
Reference in New Issue
Block a user