Make every settings row reachable, and every accessibility switch honest
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
pragma Singleton
|
||||
|
||||
// Pointer size and text scale.
|
||||
// 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
|
||||
@@ -16,6 +16,12 @@ pragma Singleton
|
||||
// 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
|
||||
@@ -93,4 +99,214 @@ Singleton {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user