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(); } } }