Give Input keycaps, a shortcut search, and the missing pointer basics

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 15:21:01 -04:00
parent b8f88a91f3
commit aba2d16ffa
25 changed files with 2346 additions and 191 deletions
@@ -16,6 +16,24 @@ pragma Singleton
// Read on demand rather than polled. Input devices do come and go -- a mouse is
// unplugged, a receiver is moved -- so this also refreshes when Hyprland says
// the device list changed, which is the only moment the answer can differ.
//
// Two views of the same data, deliberately:
//
// mice / keyboards every name libinput reports, lowercased. What the
// hardware-presence checks are built on, and what
// they must keep being built on -- a filter that
// dropped the wrong entry would hide a real control.
// realMice / realKeyboards the subset worth SHOWING a person, because the
// raw lists are mostly not devices. This machine
// reports seven "mice" and eighteen "keyboards" for
// two actual peripherals: a wireless receiver
// registers a mouse, a keyboard, three consumer
// controls and a system control, the webcam and the
// USB audio dongle each claim a keyboard, and the
// power button, the lid switch and every virtual
// typing tool are in there too. Listing all of that
// under "Connected devices" would be honest about
// libinput and useless about the desk.
import Quickshell
import Quickshell.Io
@@ -28,12 +46,69 @@ Singleton {
property var mice: []
property var keyboards: []
// The main keyboard's live layout, already in human form -- Hyprland
// resolves the XKB name for us and answers "English (US)", not "us". Empty
// until the first read finishes, so a page must treat it as optional.
property string mainKeyboardLayout: ""
// True when anything that looks like a touchpad is attached.
readonly property bool hasTouchpad: root.mice.some(name =>
name.includes("touchpad") || name.includes("trackpad"))
readonly property bool hasMouse: root.mice.length > 0
// Substrings that mark a name as an endpoint rather than a device. Matched
// anywhere in the name, because the interesting part of these is never at a
// fixed position: "generic-usb-audio-consumer-control-1" is caught twice
// over, and "onn-usb-2.0-webcam:-onn-usb-2.0" only by its middle.
//
// "fake" is here for `mouce-library-fake-mouse`, the phantom pointer the
// mouce input library registers. It is not a class of endpoint the way the
// others are, but it is precisely the kind of entry this list exists to keep
// off a card headed "Connected devices".
readonly property var phantomMarkers: [
"consumer-control", "virtual", "video-bus", "power-button",
"webcam", "audio", "uinput", "fake"
]
readonly property var realMice: root.realDevices(root.mice)
readonly property var realKeyboards: root.realDevices(root.keyboards)
// Names worth showing, as { name, pretty }.
//
// Two passes. The first drops endpoints by name. The second collapses
// siblings: one physical transceiver announces itself as
// "microsoft-...-v9.0" AND "microsoft-...-v9.0-system-control", and a
// keyboard as both its own name and "...-keyboard". Where one surviving
// name is a prefix of another, the shorter is the device and the longer is
// one of its endpoints, so the shortest of each family is the one kept.
function realDevices(names: var): var {
const kept = [];
const named = (names ?? [])
.filter(name => name && !root.phantomMarkers.some(marker => name.includes(marker)))
.sort((left, right) => left.length - right.length);
for (const name of named) {
if (kept.some(base => name.startsWith(base + "-")))
continue;
kept.push(name);
}
return kept.map(name => ({ name: name, pretty: root.prettyName(name) }));
}
// "keychron-keychron-q10" -> "Keychron Keychron Q10". Hyprland's names are
// lowercased and dash-joined; this is the smallest transform that makes one
// readable. A word starting with a digit ("2.4ghz") is left as it is rather
// than mangled.
function prettyName(name: string): string {
return String(name)
.split("-")
.map(word => word.length > 0 ? word[0].toUpperCase() + word.slice(1) : word)
.join(" ")
.trim();
}
function refresh(): void {
if (!query.running)
query.running = true;
@@ -49,6 +124,13 @@ Singleton {
const parsed = JSON.parse(this.text);
root.mice = (parsed.mice ?? []).map(device => String(device.name ?? "").toLowerCase());
root.keyboards = (parsed.keyboards ?? []).map(device => String(device.name ?? "").toLowerCase());
// Names are lowercased above because every consumer matches
// substrings against them. The keymap is not: it is already
// a display string, and "english (us)" would be a downgrade.
const boards = parsed.keyboards ?? [];
const main = boards.find(device => device.main === true) ?? boards[0];
root.mainKeyboardLayout = main ? String(main.active_keymap ?? "") : "";
} catch (error) {
console.warn("InputDevices: could not parse hyprctl devices:", error);
}