Make every settings row reachable, and every accessibility switch honest

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 21:03:36 -04:00
parent e1ff25fc66
commit 9ffaf45a4d
33 changed files with 2384 additions and 76 deletions
@@ -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();
}
}
}
+47 -11
View File
@@ -412,35 +412,71 @@ Singleton {
// dozen overlapping bells, which is a noise rather than a notification.
property real lastBellAt: 0
function playBell(notification: var): void {
if (!SoundFeedback.eventSounds)
return;
// Emitted for every notification that is bell-eligible, whether or not a
// bell is actually audible. modules/notifications/VisualBell.qml listens.
signal bellEligible(var notification)
// Would this notification ring the bell, setting aside whether sound is
// switched on at all?
//
// THE PINNED RULE, because it is the whole point of visual alerts: the
// flash follows this predicate, and the bell follows this predicate AND
// SoundFeedback.eventSounds. Every gate below is shared -- an application
// you silenced stays silent both ways, a low-urgency notification stays
// quiet both ways, and an application that says it played its own sound is
// taken at its word both ways -- but the event-sounds switch is NOT.
//
// Gating the flash on event sounds would make Visual Alerts do nothing for
// exactly the person it exists for: somebody who cannot hear the bell has
// no reason to have event sounds on, and would turn on a switch that stays
// dark. The flash is not a picture of the bell; it is the same alert in the
// sense the person can receive.
//
// Sound RESOLUTION is deliberately not part of this. playBell gives up when
// it cannot find a file to play, which is a fact about the sound theme on
// disk; losing the flash because a theme is missing an ogg would be absurd.
function bellWouldRing(notification: var): bool {
// The per-application sound switch. Narrower than turning the
// application off: its notifications still arrive and still show, they
// just stop making noise.
if (!root.appRule(root.notificationAppId(notification)).sound)
return;
return false;
// Low urgency is the "you did not need to know this" tier -- battery
// reaching full, a sync completing. It stays silent by design, and it
// is the effective urgency, so "treat as low" is a way to keep an
// application audible in principle but quiet in practice.
if (root.effectiveUrgency(notification) === NotificationUrgency.Low)
return;
return false;
// The freedesktop sound hints. This is the fix for the double chime:
// an application that plays its own sound sets suppress-sound so the
// The freedesktop sound hint. This is the fix for the double chime: an
// application that plays its own sound sets suppress-sound so the
// notification server stays quiet, and Panama ignoring it meant one
// notification made two noises a beat apart.
//
// The other two say what to play instead of the theme bell --
// sound-file is an absolute path the application supplies, sound-name
// is a theme sound resolved through the same chain the bell uses.
const hints = notification.hints ?? {};
if (hints["suppress-sound"] === true)
return false;
return true;
}
function playBell(notification: var): void {
if (!root.bellWouldRing(notification))
return;
// Announced BEFORE the event-sounds gate, on purpose. See the rule
// pinned above bellWouldRing.
root.bellEligible(notification);
if (!SoundFeedback.eventSounds)
return;
// The remaining two sound hints say what to play instead of the theme
// bell -- sound-file is an absolute path the application supplies,
// sound-name is a theme sound resolved through the same chain the bell
// uses. Neither can make a notification ineligible, so neither is part
// of the predicate above.
const hints = notification.hints ?? {};
const soundFile = String(hints["sound-file"] ?? "");
const soundName = String(hints["sound-name"] ?? "");
const candidates = soundFile.startsWith("/")
@@ -249,6 +249,21 @@ Singleton {
{ label: "Rebind a shortcut", detail: "Change the keys an action answers to, or put them back", page: "shortcuts" },
{ label: "Pointer test area", detail: "Scribble and scroll to feel a pointer change before keeping it", page: "mouse" },
{ label: "Connected input devices", detail: "The keyboards, mice, and touchpad this machine can see", page: "mouse" },
// Accessibility. The schema covers the switches by their own labels, so
// these are the things people arrive with that the schema does not say:
// the verb rather than the noun ("zoom in", not "Magnifier"), the
// application's name rather than its category ("Orca", not "Screen
// reader"), and — the one worth having most — sticky keys, which this
// desktop does not offer at all. A search that found nothing there
// would read as the desktop having no opinion, when in fact the page
// carries a paragraph explaining exactly why the switch is absent.
{ label: "Magnifier zoom", detail: "Magnify the screen around the pointer, drawn by the compositor itself", page: "accessibility" },
{ label: "Zoom in and out", detail: "Step the magnifier from anywhere with a shortcut; the OSD shows the level", page: "accessibility" },
{ label: "Reduce motion", detail: "Still the windows, the workspaces, and the shell's own bar, dock and panels", page: "accessibility" },
{ label: "Visual alerts", detail: "Flash the screen edges once when a notification arrives, instead of relying on the bell", page: "accessibility" },
{ label: "Screen reader", detail: "Start Orca and see whether the accessibility bus is up", page: "accessibility" },
{ label: "Orca", detail: "The screen reader: whether it is running, and starting or stopping it", page: "accessibility" },
{ label: "Sticky keys", detail: "Why sticky, slow and bounce keys are not offered in this session", page: "accessibility" },
{ label: "Dictation", detail: "Speech to text with Super+D, typed where the cursor is", page: "dictation" },
{ label: "Speech to text", detail: "Set up the local speech server and model", page: "dictation" },
{ label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" },