149 lines
6.6 KiB
QML
149 lines
6.6 KiB
QML
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();
|
|
}
|
|
}
|
|
}
|