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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user