pragma Singleton // What input hardware this machine actually has. // // Exists so pages can hide controls for hardware that is not present. A // touchpad card on a desktop is not merely useless -- it is misleading, because // every switch on it appears to work: the preference is stored and Hyprland // accepts the option for a device class it has no member of. The user is left // toggling settings that will never affect anything, with nothing to say so. // // Touchpads are identified by name. libinput exposes them through the same // "mice" list as everything else that reports pointer motion, and Hyprland // passes the device name through, so an Elan or Synaptics touchpad arrives as // something like "elan-touchpad". There is no device-class field to consult. // // 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 import Quickshell.Hyprland import QtQuick Singleton { id: root 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; } Process { id: query running: true command: ["hyprctl", "-j", "devices"] stdout: StdioCollector { onStreamFinished: { try { 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); } } } } Connections { target: Hyprland function onRawEvent(event: var): void { if (event.name === "device" || event.name === "configreloaded") root.refresh(); } } }