Files
Panama/config/dot/quickshell/services/Accessibility.qml
T

313 lines
13 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}
}