313 lines
13 KiB
QML
313 lines
13 KiB
QML
pragma Singleton
|
||
|
||
// Pointer size, text scale, the magnifier, and the screen reader.
|
||
//
|
||
// These are the two settings that must agree across three consumers that do not
|
||
// share a configuration system: the compositor draws the cursor, GTK
|
||
// applications read gsettings, and the shell renders its own text. Panama's
|
||
// store is the source of truth, and this pushes the value out to the other two
|
||
// so they cannot disagree.
|
||
//
|
||
// pointer size -> gsettings (GTK) + `hyprctl setcursor` (compositor)
|
||
// text scale -> gsettings (GTK)
|
||
//
|
||
// The shell's own font size is not scaled here. Theme.qml's sizes are part of
|
||
// the design rather than a user preference, and scaling them at runtime would
|
||
// reflow every panel against layouts that were tuned at the design size. Text
|
||
// scale therefore affects applications, which is where it matters, and the
|
||
// page says so rather than implying it does more.
|
||
//
|
||
// The magnifier and Orca are here too, because they are the other two things
|
||
// the Accessibility page can actually do something about. Both are deliberately
|
||
// indirect: the magnifier goes through SystemSettings.commitPreference so the
|
||
// stored value and the compositor stay the same value, and Orca is started and
|
||
// stopped as a process because there is no working desktop switch for it.
|
||
|
||
import Quickshell
|
||
import Quickshell.Io
|
||
import QtQuick
|
||
import qs.config
|
||
|
||
Singleton {
|
||
id: root
|
||
|
||
property string lastError: ""
|
||
|
||
readonly property bool busy: runner.running || root.pending.length > 0
|
||
|
||
readonly property string cursorTheme: DesktopPreferences.get("cursorTheme")
|
||
readonly property int cursorSize: DesktopPreferences.get("cursorSize")
|
||
readonly property real textScale: DesktopPreferences.get("textScale")
|
||
|
||
// A short queue, because applying one setting takes several commands and
|
||
// Process runs one at a time.
|
||
property var pending: []
|
||
|
||
Process {
|
||
id: runner
|
||
onExited: (exitCode, exitStatus) => {
|
||
if (exitCode !== 0)
|
||
root.lastError = "That accessibility setting could not be applied.";
|
||
root.drain();
|
||
}
|
||
}
|
||
|
||
function drain(): void {
|
||
if (runner.running || root.pending.length === 0)
|
||
return;
|
||
const next = root.pending[0];
|
||
root.pending = root.pending.slice(1);
|
||
runner.exec(next);
|
||
}
|
||
|
||
function enqueue(commands: var): void {
|
||
root.pending = root.pending.concat(commands);
|
||
root.drain();
|
||
}
|
||
|
||
Component.onCompleted: {
|
||
settle.restart();
|
||
}
|
||
|
||
// Push the stored values outward once at startup, so a value changed in a
|
||
// previous session is in effect in this one even though gsettings and the
|
||
// compositor do not read Panama's store.
|
||
Timer {
|
||
id: settle
|
||
interval: 1200
|
||
onTriggered: root.applyAll()
|
||
}
|
||
|
||
Connections {
|
||
target: DesktopPreferences
|
||
function onRevisionChanged(): void { coalesce.restart(); }
|
||
}
|
||
|
||
Timer {
|
||
id: coalesce
|
||
interval: 250
|
||
onTriggered: root.applyAll()
|
||
}
|
||
|
||
function applyAll(): void {
|
||
root.lastError = "";
|
||
const size = String(root.cursorSize);
|
||
const commands = [
|
||
["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size],
|
||
["gsettings", "set", "org.gnome.desktop.interface", "text-scaling-factor", String(root.textScale)]
|
||
];
|
||
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
|
||
root.enqueue(commands);
|
||
}
|
||
|
||
// ── The magnifier ───────────────────────────────────────────────────────
|
||
//
|
||
// Hyprland's own zoom (cursor:zoom_factor), stepped from the keyboard.
|
||
//
|
||
// The step is multiplicative rather than additive. Magnification is
|
||
// perceived as a ratio -- 1.00 -> 1.25 and 4.00 -> 5.00 are the same
|
||
// apparent jump -- so a fixed +0.1 would crawl at the bottom of the range,
|
||
// where somebody switching the magnifier on actually lives, and lurch at
|
||
// the top. Four presses from off reach 2.44 ×, which is roughly where a
|
||
// person reaching for a magnifier wants to be.
|
||
//
|
||
// Rounded to two decimals because that is exactly what the Magnifier slider
|
||
// displays and stores; leaving 1.953125 in the settings file would make the
|
||
// slider and the keyboard disagree about the same number.
|
||
readonly property real zoomStep: 1.25
|
||
readonly property real magnifierFactor: DesktopPreferences.get("magnifierFactor")
|
||
|
||
// What the keys have asked for, which can be a beat ahead of the store.
|
||
//
|
||
// commitPreference applies the option to the compositor and waits for
|
||
// Hyprland to confirm it before storing anything, so for a few tens of
|
||
// milliseconds after a press the stored value is still the old one. Two
|
||
// quick presses reading it would both compute the same step and the second
|
||
// would be swallowed -- which is precisely how these keys get used, in
|
||
// threes and fours. Stepping from the intent instead makes a burst of
|
||
// presses arrive where the fingers expected.
|
||
//
|
||
// -1 means "no press outstanding, the store is the truth". Cleared when the
|
||
// store catches up, and unconditionally a moment later so a commit Hyprland
|
||
// refused cannot leave the keys stepping from a magnification that never
|
||
// happened.
|
||
property real zoomIntent: -1
|
||
readonly property real requestedZoom: root.zoomIntent > 0 ? root.zoomIntent : root.magnifierFactor
|
||
|
||
onMagnifierFactorChanged: {
|
||
if (root.magnifierFactor === root.zoomIntent)
|
||
root.zoomIntent = -1;
|
||
}
|
||
|
||
Timer {
|
||
id: zoomIntentExpiry
|
||
interval: 1500
|
||
onTriggered: root.zoomIntent = -1
|
||
}
|
||
|
||
// The keyboard's entry point, called from shell.qml's `accessibility` IPC
|
||
// target (SUPER+ALT+= / - / 0 in hypr/keybinds.lua).
|
||
//
|
||
// NEVER `hyprctl keyword cursor:zoom_factor`. That would change the screen
|
||
// without changing the preference, and the Magnifier slider would then be
|
||
// showing a magnification nobody is looking at. commitPreference is the one
|
||
// path that applies the option, verifies Hyprland took it, and only then
|
||
// stores it -- the same path the slider uses, so the two cannot drift.
|
||
//
|
||
// Returns a line for `qs ipc call`, so the shortcut can be tested from a
|
||
// terminal and say what it did.
|
||
function stepZoom(direction: string): string {
|
||
const spec = PreferenceSchema.spec("magnifierFactor");
|
||
const minimum = spec.min;
|
||
const maximum = spec.max;
|
||
const current = root.requestedZoom;
|
||
|
||
let next;
|
||
switch (direction) {
|
||
case "in":
|
||
next = current * root.zoomStep;
|
||
break;
|
||
case "out":
|
||
next = current / root.zoomStep;
|
||
break;
|
||
case "reset":
|
||
next = minimum;
|
||
break;
|
||
default:
|
||
return `Unknown zoom direction "${direction}". Use in, out or reset.`;
|
||
}
|
||
|
||
next = Math.max(minimum, Math.min(maximum, Math.round(next * 100) / 100));
|
||
|
||
// Nothing is written when the step lands where we already are -- at
|
||
// either end of the range, or on reset with the magnifier already off.
|
||
// The OSD still fires below, though: a key that appears to do nothing
|
||
// is indistinguishable from a broken one, and "5.00 ×" is the answer to
|
||
// why pressing it again did not help.
|
||
if (next !== current) {
|
||
if (!SystemSettings.commitPreference("magnifierFactor", next)) {
|
||
root.zoomIntent = -1;
|
||
root.lastError = SystemSettings.lastError || "The magnification could not be changed.";
|
||
return root.lastError;
|
||
}
|
||
root.zoomIntent = next;
|
||
zoomIntentExpiry.restart();
|
||
}
|
||
|
||
root.showZoomOsd(next);
|
||
return `Magnifier ${root.zoomLabel(next)}`;
|
||
}
|
||
|
||
// The same text the Magnifier slider shows, from the same schema unit, so
|
||
// the OSD and the settings page never word the same number differently.
|
||
function zoomLabel(factor: real): string {
|
||
const spec = PreferenceSchema.spec("magnifierFactor");
|
||
return `${factor.toFixed(2)} ${spec.unit}`;
|
||
}
|
||
|
||
// The generic progress OSD, with "zoom" as its kind (OsdModel maps that to
|
||
// the zoom-in icon). The bar is drawn over the USABLE range -- 1.00 × is an
|
||
// empty bar rather than a fifth-full one, because 1.00 × is off.
|
||
function showZoomOsd(factor: real): void {
|
||
const spec = PreferenceSchema.spec("magnifierFactor");
|
||
const span = Math.round((spec.max - spec.min) * 100);
|
||
OsdState.progress("zoom", Math.round((factor - spec.min) * 100), span, root.zoomLabel(factor));
|
||
}
|
||
|
||
// ── The screen reader ───────────────────────────────────────────────────
|
||
//
|
||
// Orca, reported as what it is: a process that is either running or not.
|
||
//
|
||
// There is no desktop switch to offer instead. GNOME's
|
||
// org.gnome.desktop.a11y.applications screen-reader-enabled key is acted on
|
||
// by gnome-settings-daemon, which does not run in this session, so writing
|
||
// it would store a preference that starts nothing -- exactly the kind of
|
||
// dead switch this page exists to not ship.
|
||
//
|
||
// `pgrep -x orca` is correct even though orca is a Python script: it calls
|
||
// set_process_name("orca") at startup (/usr/sbin/orca), which goes through
|
||
// setproctitle or prctl(PR_SET_NAME) and renames comm, which is what pgrep
|
||
// matches without -f. Verified against the installed orca, not assumed.
|
||
property bool orcaRunning: false
|
||
|
||
// Whether the accessibility bus is actually up, so the page's readiness
|
||
// line is probed rather than claimed. org.a11y.Bus is what Orca and every
|
||
// AT-SPI client connect through; if it is missing, Orca will start and read
|
||
// nothing, and saying so beforehand is cheaper than the confusion.
|
||
property bool accessibilityBusRunning: false
|
||
|
||
// Probed on demand -- when the Accessibility page opens, and after Start or
|
||
// Stop -- rather than on a timer. Nothing on this desktop needs to know
|
||
// about Orca while the page is closed, and a poll that runs all session to
|
||
// answer a question nobody asked is a cost with no reader.
|
||
function refreshScreenReader(): void {
|
||
if (!screenReaderQuery.running)
|
||
screenReaderQuery.running = true;
|
||
}
|
||
|
||
Process {
|
||
id: screenReaderQuery
|
||
command: [
|
||
"bash", "-lc",
|
||
"printf '%s %s' "
|
||
+ "$(pgrep -x orca >/dev/null 2>&1 && printf true || printf false) "
|
||
+ "$(gdbus call --session --dest org.a11y.Bus --object-path /org/a11y/bus "
|
||
+ "--method org.a11y.Bus.GetAddress >/dev/null 2>&1 && printf true || printf false)"
|
||
]
|
||
stdout: StdioCollector {
|
||
onStreamFinished: {
|
||
const fields = this.text.trim().split(/\s+/);
|
||
root.orcaRunning = fields[0] === "true";
|
||
root.accessibilityBusRunning = fields[1] === "true";
|
||
}
|
||
}
|
||
}
|
||
|
||
// Start and stop are separate functions rather than one toggle, because the
|
||
// button they back says which one it is doing. A toggle reading stale state
|
||
// would start a second Orca or stop nothing.
|
||
//
|
||
// Detached on purpose, and not through this file's Process queue: a queued
|
||
// Process is a CHILD of the shell, so every hot reload of Quickshell would
|
||
// take the screen reader down with it. Somebody who needs Orca to read the
|
||
// screen must not lose it because a panel was edited.
|
||
function startOrca(): void {
|
||
root.lastError = "";
|
||
Quickshell.execDetached(["orca"]);
|
||
root.settleScreenReader();
|
||
}
|
||
|
||
function stopOrca(): void {
|
||
root.lastError = "";
|
||
// -x for the same reason the probe uses it, and by name rather than by
|
||
// the PID the probe saw: the probe is a snapshot from some moments ago,
|
||
// and killing a remembered PID is how you end up killing whatever
|
||
// reused it.
|
||
Quickshell.execDetached(["pkill", "-x", "orca"]);
|
||
root.settleScreenReader();
|
||
}
|
||
|
||
// Orca takes a couple of seconds to come up and a moment to go down, so one
|
||
// probe fired immediately after the action would report the state that just
|
||
// changed. Three probes, then stop: bounded by construction, so a failure
|
||
// to start cannot leave a poll running for the rest of the session.
|
||
property int settleTicks: 0
|
||
|
||
function settleScreenReader(): void {
|
||
root.settleTicks = 0;
|
||
screenReaderSettle.restart();
|
||
}
|
||
|
||
Timer {
|
||
id: screenReaderSettle
|
||
interval: 900
|
||
repeat: true
|
||
onTriggered: {
|
||
root.settleTicks += 1;
|
||
root.refreshScreenReader();
|
||
if (root.settleTicks >= 3)
|
||
screenReaderSettle.stop();
|
||
}
|
||
}
|
||
}
|