Make every settings row reachable, and every accessibility switch honest
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -151,7 +151,7 @@ docs/ Settings reference, and the design specs behind the work
|
||||
|
||||
## Tests
|
||||
|
||||
174 of them, under `tests/`. Run the lot, or a subset by pattern:
|
||||
175 of them, under `tests/`. Run the lot, or a subset by pattern:
|
||||
|
||||
```sh
|
||||
panama test # everything
|
||||
|
||||
@@ -119,8 +119,16 @@ a polished general-purpose desktop can go beyond the current shell.
|
||||
3. A keyboard-layout change notice. Hyprland reports the active keymap but not
|
||||
a change event Quickshell already consumes, so this needs either polling or
|
||||
new event plumbing, and a single-layout machine cannot test it.
|
||||
4. Sticky keys, slow keys and bounce keys. AccessX is an X11 server feature
|
||||
with no Wayland equivalent; GNOME, macOS and Windows all ship these.
|
||||
4. Sticky keys, slow keys and bounce keys. Wayland has no protocol for these,
|
||||
so each compositor implements them for itself — mutter does, which is how
|
||||
GNOME has them on Wayland, and Hyprland does not. There is no XKB option to
|
||||
lean on either: the accessx option group is X11-only and does not appear in
|
||||
evdev.lst at all (checked, not assumed — `grep -c accessx
|
||||
/usr/share/X11/xkb/rules/evdev.lst` is 0), and Hyprland will happily store
|
||||
`accessx:enable` as a keyboard option that nothing ever acts on. This is a
|
||||
Hyprland gap rather than a Wayland impossibility, and the Accessibility page
|
||||
says so in those terms; an earlier version of this line blamed X11 and sent
|
||||
anyone who needs sticky keys to the wrong conclusion about the platform.
|
||||
5. An on-screen keyboard, for a touch or convertible machine.
|
||||
|
||||
KDE Connect, the printer UI, Tesseract, and ZBar are installed and remain
|
||||
|
||||
@@ -262,6 +262,44 @@ bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
|
||||
-- Color picker: copies the hex under the cursor to the clipboard.
|
||||
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Color picker" })
|
||||
|
||||
-- ── Magnifier ───────────────────────────────────────────────────────────────
|
||||
category("Shell")
|
||||
--
|
||||
-- The chords are NOT the obvious SUPER+=/-/0. SUPER+equal is already "Reset
|
||||
-- split" (Window management, below), and taking a daily tiling key away to
|
||||
-- give the magnifier the prettiest chord on the keyboard is the wrong trade.
|
||||
--
|
||||
-- SUPER+ALT is where they went instead, which is also where GNOME's magnifier
|
||||
-- lives: gsettings' magnifier-zoom-in / magnifier-zoom-out ship as
|
||||
-- <Alt><Super>= and <Alt><Super>-, so this is the shortcut the machine this
|
||||
-- desktop replaced already had. SUPER+ALT+0 -- free; the workspace digits are
|
||||
-- plain ALT -- resets to 1.00 ×, reading as "back to zero magnification".
|
||||
--
|
||||
-- These go THROUGH the shell rather than calling `hyprctl keyword
|
||||
-- cursor:zoom_factor` directly. Setting the compositor option behind Panama's
|
||||
-- back would leave the stored preference and the Magnifier slider claiming a
|
||||
-- magnification that is not the one on screen; the IPC call commits through
|
||||
-- the same verified-preference path the slider uses, so the store, the
|
||||
-- compositor and the settings page can never disagree. It also posts the OSD,
|
||||
-- which is the only way to see what the factor now is with the pointer
|
||||
-- somewhere else entirely.
|
||||
--
|
||||
-- Not `repeating`: the step is multiplicative (×1.25), so a held key repeating
|
||||
-- at the keyboard rate would arrive at the 5.00 × ceiling in about a tenth of
|
||||
-- a second. One press, one step.
|
||||
--
|
||||
-- Written as literal chords rather than `mod .. " + ALT + ..."` (as
|
||||
-- "SUPER + Backspace" already is, above) because these three are the most
|
||||
-- collision-prone binds in the file -- they were placed around one -- and a
|
||||
-- literal is the form both the duplicate-chord check and the settings page's
|
||||
-- chord display can actually read.
|
||||
bind("SUPER + ALT + equal", hl.dsp.exec_cmd(qs("accessibility", "zoom in")),
|
||||
{ description = "Zoom in" })
|
||||
bind("SUPER + ALT + minus", hl.dsp.exec_cmd(qs("accessibility", "zoom out")),
|
||||
{ description = "Zoom out" })
|
||||
bind("SUPER + ALT + 0", hl.dsp.exec_cmd(qs("accessibility", "zoom reset")),
|
||||
{ description = "Reset zoom" })
|
||||
|
||||
-- ── Window management ───────────────────────────────────────────────────────
|
||||
category("Windows")
|
||||
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
|
||||
|
||||
@@ -1022,9 +1022,14 @@ Singleton {
|
||||
// this app refuses to ship.
|
||||
{
|
||||
key: "magnifierFactor", type: "real", def: 1.0, min: 1.0, max: 5.0, step: 0.1,
|
||||
group: "accessibility",
|
||||
unit: "×", group: "accessibility",
|
||||
label: "Magnifier",
|
||||
detail: "Magnifies the screen around the pointer. 1.0 is off",
|
||||
// The readout is "1.00 ×", so the detail says "1.00 ×" too. It used
|
||||
// to say "1.0 is off" beside a slider reading 1.00, and the page
|
||||
// carried a `zeroLabel: "Off"` that could never fire: the minimum
|
||||
// IS 1.0, so the value is never 0 and the zero label was dead copy.
|
||||
// Off is a magnification of one, and that is what both lines say.
|
||||
detail: "Magnifies the screen around the pointer. 1.00 × is off",
|
||||
hypr: { path: ["cursor", "zoom_factor"], option: "cursor:zoom_factor", readAs: "float" }
|
||||
},
|
||||
{
|
||||
@@ -1054,6 +1059,16 @@ Singleton {
|
||||
detail: "How much darker unfocused windows are",
|
||||
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" }
|
||||
},
|
||||
{
|
||||
key: "visualAlerts", type: "bool", def: false, group: "accessibility",
|
||||
label: "Flash the screen for notifications",
|
||||
// No hypr mapping and no gsettings mapping: the flash is drawn by
|
||||
// modules/notifications/VisualBell.qml, one per screen, and fires
|
||||
// on the same notifications the bell would ring for -- except that
|
||||
// it is deliberately NOT gated on the event-sounds switch, since a
|
||||
// visual alert exists for people who cannot hear the bell.
|
||||
detail: "A single flash at the edges of every screen when a notification arrives that would ring the bell"
|
||||
},
|
||||
|
||||
// ── Gaming ──────────────────────────────────────────────────────────
|
||||
// What Panama does while a game runs. gamemode tells us when that
|
||||
|
||||
@@ -86,6 +86,18 @@ Singleton {
|
||||
// services/Notifs.qml. Off means Do Not Disturb is absolute.
|
||||
readonly property bool criticalBreaksThrough: DesktopPreferences.get("criticalBreaksThrough")
|
||||
|
||||
// ── Accessibility ───────────────────────────────────────────────────────
|
||||
// Reduce motion. Theme.qml turns this into the dur* tokens, so every
|
||||
// Behavior and NumberAnimation in the shell obeys it without knowing it
|
||||
// exists. Read through here rather than from the store directly because
|
||||
// Theme reads it on every animated property in the shell.
|
||||
readonly property bool animationsEnabled: DesktopPreferences.get("animationsEnabled")
|
||||
|
||||
// Read on every notification that would ring the bell, by the per-screen
|
||||
// VisualBell overlay. Lives here rather than being read from the store
|
||||
// directly, like every other value the shell consults at speed.
|
||||
readonly property bool visualAlerts: DesktopPreferences.get("visualAlerts")
|
||||
|
||||
// ── Sound ───────────────────────────────────────────────────────────────
|
||||
// Over-amplification is the clamp ceiling for output volume: off means 1.0,
|
||||
// on means 1.5. Every slider and the volume keys read the same switch, so
|
||||
|
||||
@@ -204,14 +204,21 @@ Singleton {
|
||||
// ── Motion ──────────────────────────────────────────────────────────────
|
||||
// Event-driven only. Nothing in this shell animates while idle — no pulse,
|
||||
// no shimmer, no spinners. These durations are used for open/close/hover.
|
||||
readonly property int durFast: 120
|
||||
readonly property int durNormal: 200
|
||||
readonly property int durSlow: 320
|
||||
//
|
||||
// All of them collapse to zero when Reduce motion is on: the Accessibility
|
||||
// toggle used to still the compositor's windows while the shell's own bar,
|
||||
// dock and panels kept moving, which made it a half-truth. A duration of 0
|
||||
// is a completed animation, so every Behavior and NumberAnimation in the
|
||||
// shell obeys the switch without knowing it exists.
|
||||
readonly property bool motionEnabled: Settings.animationsEnabled
|
||||
readonly property int durFast: motionEnabled ? 120 : 0
|
||||
readonly property int durNormal: motionEnabled ? 200 : 0
|
||||
readonly property int durSlow: motionEnabled ? 320 : 0
|
||||
|
||||
// The dock revealing is the one animation that answers a live pointer
|
||||
// movement, so it gets its own (much shorter) duration. Anything slower
|
||||
// reads as the desktop lagging behind the cursor rather than as motion.
|
||||
readonly property int durDockReveal: 90
|
||||
readonly property int durDockReveal: motionEnabled ? 90 : 0
|
||||
|
||||
// Matches the "snappy" spring curve defined in hypr/looks.lua.
|
||||
readonly property list<real> easeStandard: [0.05, 0.9, 0.1, 1.0]
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// The visual bell: one flash at the edges of the screen when a notification
|
||||
// arrives that would have rung.
|
||||
//
|
||||
// For people who cannot hear the bell. It fires on exactly the notifications
|
||||
// services/Notifs.qml calls bell-eligible -- same per-application switch, same
|
||||
// low-urgency rule, same suppress-sound hint -- but NOT on the event-sounds
|
||||
// switch, which would make this do nothing for the person it is for. That rule
|
||||
// is pinned in Notifs.qml above `bellWouldRing`; this file only listens.
|
||||
//
|
||||
// Edges rather than the whole screen. A full-screen white flash is what X11's
|
||||
// visual bell did, and it is genuinely unpleasant: it destroys dark adaptation,
|
||||
// hides the thing you were reading at the moment it demands attention, and is
|
||||
// the shape of flash that photosensitivity guidance warns about. A soft glow
|
||||
// inward from the four edges is unmissable in peripheral vision and leaves the
|
||||
// middle of the screen -- the part being read -- alone.
|
||||
//
|
||||
// ONE animation per notification. There is no `loops`, no Timer that restarts
|
||||
// it, and a burst of notifications cannot stack flashes: while the animation is
|
||||
// running, further triggers are ignored outright. A strobing screen is a
|
||||
// seizure risk, not a notification.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
property var modelData: null
|
||||
screen: root.modelData
|
||||
|
||||
// Mapped only while flashing. The rest of the session this costs nothing,
|
||||
// and no surface sits over the desktop waiting for something to happen.
|
||||
property bool mapped: false
|
||||
visible: root.mapped
|
||||
|
||||
anchors.top: true
|
||||
anchors.bottom: true
|
||||
anchors.left: true
|
||||
anchors.right: true
|
||||
|
||||
// Reserve nothing and respect nothing: the glow is drawn over the whole
|
||||
// output including under the bar and the dock, which is what makes it
|
||||
// visible from wherever the eyes happen to be.
|
||||
exclusiveZone: 0
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
color: "transparent"
|
||||
|
||||
WlrLayershell.namespace: "qs-visual-bell"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
|
||||
// Entirely click-through: an empty mask means no part of this surface
|
||||
// takes a pointer event, so a flash cannot swallow the click you were in
|
||||
// the middle of making.
|
||||
mask: Region {}
|
||||
|
||||
// How far the glow reaches in from each edge. Fixed pixels rather than a
|
||||
// share of the screen: this is about peripheral vision, which does not
|
||||
// scale with the size of the monitor.
|
||||
readonly property int reach: 72
|
||||
|
||||
// Deliberately NOT Theme.durFast / Theme.durNormal. Those collapse to zero
|
||||
// when Reduce motion is on, which would make the flash instantaneous and
|
||||
// therefore invisible -- switching on Reduce motion would silently switch
|
||||
// off Visual alerts. A flash is information, not decoration, so it keeps
|
||||
// its own timings. They are slow enough not to strobe and quick enough to
|
||||
// be over before it becomes irritating.
|
||||
readonly property int riseMs: 110
|
||||
readonly property int fallMs: 340
|
||||
|
||||
Connections {
|
||||
target: Notifs
|
||||
function onBellEligible(notification: var): void { root.flash(); }
|
||||
}
|
||||
|
||||
// The one-shot. A trigger arriving mid-flash is dropped rather than
|
||||
// queued or restarted, so ten notifications landing together are one
|
||||
// flash -- the same coalescing the audible bell gets from its throttle.
|
||||
function flash(): void {
|
||||
if (!Settings.visualAlerts || pulse.running)
|
||||
return;
|
||||
root.mapped = true;
|
||||
pulse.restart();
|
||||
}
|
||||
|
||||
Item {
|
||||
id: glow
|
||||
|
||||
anchors.fill: parent
|
||||
opacity: 0
|
||||
|
||||
readonly property color tint: Theme.alpha(Theme.accent, 0.62)
|
||||
readonly property color fade: Theme.alpha(Theme.accent, 0)
|
||||
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: root.reach
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: glow.tint }
|
||||
GradientStop { position: 1.0; color: glow.fade }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: root.reach
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: glow.fade }
|
||||
GradientStop { position: 1.0; color: glow.tint }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: root.reach
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: glow.tint }
|
||||
GradientStop { position: 1.0; color: glow.fade }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: root.reach
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: glow.fade }
|
||||
GradientStop { position: 1.0; color: glow.tint }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Up, down, gone. One sequence, run once per notification: no `loops`, no
|
||||
// repeat, and the window unmaps itself at the end so nothing is left over
|
||||
// the desktop between notifications.
|
||||
SequentialAnimation {
|
||||
id: pulse
|
||||
|
||||
NumberAnimation {
|
||||
target: glow
|
||||
property: "opacity"
|
||||
from: 0
|
||||
to: 1
|
||||
duration: root.riseMs
|
||||
easing.type: Easing.OutCubic
|
||||
}
|
||||
NumberAnimation {
|
||||
target: glow
|
||||
property: "opacity"
|
||||
to: 0
|
||||
duration: root.fallMs
|
||||
easing.type: Easing.InCubic
|
||||
}
|
||||
ScriptAction { script: root.mapped = false }
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,11 @@ function iconFor(kind, ratio) {
|
||||
return "audio-input-microphone-symbolic";
|
||||
if (name === "brightness")
|
||||
return "display-brightness-symbolic";
|
||||
// The magnifier, stepped from SUPER+ALT+= / - / 0. Ratio 0 is 1.00 ×,
|
||||
// which is the magnifier switched off, so it gets the "actual size" icon
|
||||
// rather than a magnifying glass claiming to be magnifying.
|
||||
if (name === "zoom")
|
||||
return ratio <= 0 ? "zoom-original-symbolic" : "zoom-in-symbolic";
|
||||
if (name === "media-play" || name === "media-playing")
|
||||
return "media-playback-start-symbolic";
|
||||
if (name === "media-pause" || name === "media-paused")
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
// Accessibility.
|
||||
//
|
||||
// Organised by what a person came here unable to do -- see it, tolerate the
|
||||
// motion, hear it, reach it from the keyboard, have it read aloud -- rather
|
||||
// than by which subsystem happens to implement each control. The old page was
|
||||
// grouped by mechanism (Pointer / Text / Motion / Magnifier / Contrast), which
|
||||
// is the shape of the code and not the shape of the question.
|
||||
//
|
||||
// Everything on this page acts on this session. Two things deliberately do not
|
||||
// ship as switches -- mono audio, and sticky/slow/bounce keys -- and both say
|
||||
// why in place instead of being quietly absent or, worse, present and dead.
|
||||
//
|
||||
// Pointer size and text scale have to agree across three consumers that share
|
||||
// no configuration system -- the compositor, GTK applications, and the shell.
|
||||
// Panama's store is the source of truth and services/Accessibility.qml pushes
|
||||
@@ -13,75 +23,205 @@ SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Accessibility"
|
||||
lede: "Make the desktop easier to see and easier to hit."
|
||||
lede: "Every switch on this page does something on this desktop — and the ones that cannot yet say why."
|
||||
|
||||
SettingsCard {
|
||||
title: "Pointer"
|
||||
subtitle: "Applied to the compositor and to applications at the same time."
|
||||
// The zoom chords come from the compositor's live keymap, matched on the
|
||||
// descriptions hypr/keybinds.lua gives them, so rebinding a zoom key
|
||||
// changes what this page says instead of quietly making it wrong. The
|
||||
// literals are the shipped chords, standing in only until the keymap has
|
||||
// loaded -- not a second source of truth.
|
||||
function chordFor(needle: string, fallback: string): string {
|
||||
for (const bind of Keybinds.binds) {
|
||||
if (String(bind.description).toLowerCase().indexOf(needle) >= 0)
|
||||
return String(bind.chord);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
SliderRow { setting: "cursorSize" }
|
||||
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
|
||||
readonly property string zoomInChord: root.chordFor("zoom in", "Super + Alt + =")
|
||||
readonly property string zoomOutChord: root.chordFor("zoom out", "Super + Alt + -")
|
||||
readonly property string zoomResetChord: root.chordFor("reset zoom", "Super + Alt + 0")
|
||||
|
||||
// Probed when the page opens rather than polled all session: nothing else
|
||||
// on this desktop needs to know whether Orca is running.
|
||||
Component.onCompleted: Accessibility.refreshScreenReader()
|
||||
|
||||
readonly property string screenReaderDetail: {
|
||||
if (Accessibility.orcaRunning)
|
||||
return "Running — reading the focused application";
|
||||
if (Accessibility.accessibilityBusRunning)
|
||||
return "Not running · the accessibility bus is up, so applications are ready to be read";
|
||||
return "Not running · the accessibility bus is not up, so Orca would start and read nothing";
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Text"
|
||||
subtitle: "Scales text in applications. The shell's own panels are drawn at their design size, so they are unaffected."
|
||||
title: "Vision"
|
||||
subtitle: "Magnification is the compositor's own, so it follows the pointer across every window and every screen."
|
||||
|
||||
SliderRow { setting: "magnifierFactor" }
|
||||
|
||||
// One row per chord rather than three chords crammed into one row's
|
||||
// trailing slot: three keycap chords side by side are wider than the
|
||||
// control column, and squeezing them there costs the label its line.
|
||||
SettingRow {
|
||||
label: "Zoom in"
|
||||
detail: "Works from anywhere — the OSD shows the magnification you land on"
|
||||
controlWidth: 210
|
||||
|
||||
KeycapChord {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
chord: root.zoomInChord
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Zoom out"
|
||||
controlWidth: 210
|
||||
|
||||
KeycapChord {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
chord: root.zoomOutChord
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Back to 1.00 ×"
|
||||
detail: "Turns the magnifier off without coming back to Settings for it"
|
||||
controlWidth: 210
|
||||
|
||||
KeycapChord {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
chord: root.zoomResetChord
|
||||
}
|
||||
}
|
||||
|
||||
ToggleRow { setting: "magnifierRigid" }
|
||||
SliderRow { setting: "textScale" }
|
||||
// Reaches GTK4 applications through the desktop portal, which
|
||||
// republishes it as org.freedesktop.appearance contrast. No
|
||||
// high-contrast theme is involved, and none is installed here -- older
|
||||
// GTK3 applications will not change.
|
||||
SliderRow { setting: "cursorSize" }
|
||||
ToggleRow { setting: "highContrast"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Motion"
|
||||
subtitle: "Nothing animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing."
|
||||
subtitle: "Nothing animates while idle. This is the motion you asked for — windows opening, workspaces sliding, panels appearing."
|
||||
|
||||
ToggleRow { setting: "animationsEnabled"; divider: false }
|
||||
}
|
||||
// The detail says more than the schema's does because on this page the
|
||||
// claim is the point: the shell's own durations now collapse to zero
|
||||
// when this is off, so the bar, the dock and the panels genuinely stop
|
||||
// moving. Until that landed, this switch reached the compositor and
|
||||
// left the shell animating over the top of it.
|
||||
ToggleRow {
|
||||
setting: "animationsEnabled"
|
||||
detail: "Window, workspace and panel motion — including the shell's own bar, dock and panels, which now stop with everything else"
|
||||
}
|
||||
|
||||
// Zoom, done by the compositor rather than handed to GNOME. Hyprland has a
|
||||
// real magnifier (cursor:zoom_factor) that follows the pointer, so there is
|
||||
// no reason to send someone to another application for it.
|
||||
SettingsCard {
|
||||
title: "Magnifier"
|
||||
subtitle: "Magnifies the screen around the pointer. Set the magnification to 1× to turn it off."
|
||||
|
||||
SliderRow { setting: "magnifierFactor"; zeroLabel: "Off" }
|
||||
ToggleRow { setting: "magnifierRigid"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Contrast"
|
||||
subtitle: "Unfocused windows can be faded or darkened to make the focused one obvious, or left alone if that is harder to read."
|
||||
|
||||
SliderRow { setting: "inactiveOpacity" }
|
||||
ToggleRow { setting: "dimInactive" }
|
||||
SliderRow { setting: "dimStrength"; divider: false }
|
||||
|
||||
// The amount only means anything while dimming is on, so it goes quiet
|
||||
// rather than disappearing: a row that vanishes takes the explanation
|
||||
// of what the switch above it does with it.
|
||||
SliderRow {
|
||||
setting: "dimStrength"
|
||||
enabled: DesktopPreferences.get("dimInactive") === true
|
||||
opacity: enabled ? 1 : 0.4
|
||||
}
|
||||
|
||||
SliderRow { setting: "inactiveOpacity"; divider: false }
|
||||
}
|
||||
|
||||
// What this session genuinely cannot do, said plainly -- and for the
|
||||
// right reason. On Wayland there is no protocol for sticky, slow or
|
||||
// bounce keys: each compositor implements its own (mutter does, which is
|
||||
// how GNOME has them on Wayland), and Hyprland does not yet. An earlier
|
||||
// version blamed "X11 feature with no Wayland equivalent", which sent
|
||||
// anyone who needs sticky keys to the wrong conclusion about the whole
|
||||
// platform. There is also deliberately no handoff to GNOME's
|
||||
// universal-access panel: its toggles are applied by GNOME Shell, and
|
||||
// the few that work through plain gsettings (cursor size, text scale,
|
||||
// high contrast) are owned by the controls above on this very page.
|
||||
SettingsCard {
|
||||
title: "Keyboard accessibility"
|
||||
subtitle: "Sticky, slow and bounce keys are implemented by each Wayland compositor for itself; Hyprland does not implement them yet, so they are unavailable in this session. Offering switches here would store preferences nothing acts on."
|
||||
title: "Hearing"
|
||||
subtitle: "What the desktop does instead of making a sound."
|
||||
|
||||
ToggleRow { setting: "visualAlerts" }
|
||||
|
||||
SettingRow {
|
||||
label: "Mono audio"
|
||||
detail: "Not offered yet — folding stereo into one channel is a real change to the PipeWire graph, and a switch that only looked like it did that would be worse than its absence. Balance lives on the Sound page meanwhile."
|
||||
controlWidth: 90
|
||||
divider: false
|
||||
|
||||
SoundBadge {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Not yet"
|
||||
tone: Theme.fgMuted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Keyboard & pointer"
|
||||
subtitle: "Key behaviour lives with the Keyboard settings; what the pointer does when you stop moving it lives here."
|
||||
|
||||
ActionRow {
|
||||
label: "Screen reader"
|
||||
detail: "Orca reads the screen aloud and works over the accessibility bus, which does run here"
|
||||
action: "Start Orca"
|
||||
label: "Key repeat"
|
||||
detail: "Delay and speed are on the Keyboard page, with the rest of the keymap"
|
||||
action: "Open Keyboard"
|
||||
onTriggered: ShellState.openSettings("shortcuts")
|
||||
}
|
||||
|
||||
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
|
||||
|
||||
// The card's rows sit flush against each other; the note is a
|
||||
// separate thing and needs the gap to read as one.
|
||||
Item { width: 1; height: 10 }
|
||||
|
||||
// The receipts, not a warning. See SettingsNote for why this is quiet.
|
||||
SettingsNote {
|
||||
headline: "Sticky, slow and bounce keys are not offered"
|
||||
body: "Hyprland has no such options — asked of the running compositor rather than assumed — and GNOME's switches for them are applied by a daemon this session does not run, so a switch here would be wired to nothing. On Wayland each compositor implements these for itself; if Hyprland grows them, they land on this page."
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Screen reader"
|
||||
subtitle: "Orca is a separate application and is reported as one: either its process is running or it is not."
|
||||
|
||||
SettingRow {
|
||||
label: "Orca"
|
||||
detail: root.screenReaderDetail
|
||||
controlWidth: 200
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
SoundBadge {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Accessibility.orcaRunning ? "Running" : "Stopped"
|
||||
tone: Accessibility.orcaRunning ? Theme.ok : Theme.fgMuted
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: Accessibility.orcaRunning ? "Stop" : "Start"
|
||||
onClicked: {
|
||||
if (Accessibility.orcaRunning)
|
||||
Accessibility.stopOrca();
|
||||
else
|
||||
Accessibility.startOrca();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "This app, read aloud"
|
||||
detail: "Every settings row carries a spoken name and takes keyboard focus — Tab walks the page, Space flips a switch, the arrows move a slider or step a choice"
|
||||
controlWidth: 90
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openApplication("orca")
|
||||
|
||||
SoundBadge {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Built in"
|
||||
tone: Theme.ok
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ SettingRow {
|
||||
|
||||
controlWidth: Math.max(110, button.implicitWidth + 8)
|
||||
|
||||
function press(): void {
|
||||
if (root.enabled)
|
||||
root.triggered();
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: button
|
||||
anchors.right: parent.right
|
||||
@@ -30,5 +35,33 @@ SettingRow {
|
||||
text: root.action
|
||||
enabled: root.enabled
|
||||
onClicked: root.triggered()
|
||||
|
||||
activeFocusOnTab: root.enabled
|
||||
|
||||
// The row's label is what the button is FOR; the button's own caption
|
||||
// is what pressing it does. Both are said, in that order.
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: root.label
|
||||
Accessible.description: root.detail === ""
|
||||
? root.action
|
||||
: `${root.detail} — ${root.action}`
|
||||
Accessible.focusable: root.enabled
|
||||
Accessible.focused: button.activeFocus
|
||||
Accessible.onPressAction: root.press()
|
||||
|
||||
Keys.onReturnPressed: root.press()
|
||||
Keys.onEnterPressed: root.press()
|
||||
Keys.onSpacePressed: root.press()
|
||||
|
||||
// Focus ring only -- the button keeps its own border at rest.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: -3
|
||||
radius: 11
|
||||
color: "transparent"
|
||||
visible: button.activeFocus
|
||||
border.width: 2
|
||||
border.color: Theme.accentSecondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,28 @@ SettingRow {
|
||||
detail: root.spec ? root.spec.detail : ""
|
||||
controlWidth: Math.max(120, root.options.length * 92)
|
||||
|
||||
readonly property var currentOption:
|
||||
root.options.find(option => option.value === root.current) ?? null
|
||||
|
||||
// Left and Right move one segment along and commit, which is what the
|
||||
// segments already do under a click -- the arrows are just the other way to
|
||||
// reach the same neighbour. It never wraps: running off the end of a
|
||||
// segmented control silently landing on the far end is how a keyboard user
|
||||
// sets something they did not mean to.
|
||||
function stepChoice(delta: int): void {
|
||||
if (root.options.length === 0)
|
||||
return;
|
||||
const at = root.options.findIndex(option => option.value === root.current);
|
||||
const from = at < 0 ? 0 : at;
|
||||
const next = Math.max(0, Math.min(root.options.length - 1, from + delta));
|
||||
if (next === at)
|
||||
return;
|
||||
SystemSettings.commitPreference(root.setting, root.options[next].value);
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: group
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
implicitWidth: segments.implicitWidth + 4
|
||||
@@ -33,6 +54,35 @@ SettingRow {
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
border.width: 0
|
||||
|
||||
activeFocusOnTab: true
|
||||
|
||||
Accessible.role: Accessible.ComboBox
|
||||
Accessible.name: root.label
|
||||
Accessible.description: {
|
||||
const chosen = root.currentOption
|
||||
? String(root.currentOption.label ?? "")
|
||||
: "nothing selected";
|
||||
return root.detail === "" ? chosen : `${root.detail} — ${chosen}`;
|
||||
}
|
||||
Accessible.focusable: true
|
||||
Accessible.focused: group.activeFocus
|
||||
|
||||
Keys.onLeftPressed: root.stepChoice(-1)
|
||||
Keys.onUpPressed: root.stepChoice(-1)
|
||||
Keys.onRightPressed: root.stepChoice(1)
|
||||
Keys.onDownPressed: root.stepChoice(1)
|
||||
|
||||
// Focus ring only -- the strip keeps its borderless plate at rest.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: -3
|
||||
radius: 11
|
||||
color: "transparent"
|
||||
visible: group.activeFocus
|
||||
border.width: 2
|
||||
border.color: Theme.accentSecondary
|
||||
}
|
||||
|
||||
Row {
|
||||
id: segments
|
||||
anchors.centerIn: parent
|
||||
@@ -54,6 +104,13 @@ SettingRow {
|
||||
border.width: 0
|
||||
color: "transparent"
|
||||
|
||||
// Named individually so the reader says which option it is
|
||||
// on, not just that a choice exists. Not a Tab stop: the
|
||||
// strip is one stop and the arrows walk it.
|
||||
Accessible.role: Accessible.RadioButton
|
||||
Accessible.name: String(segment.modelData.label ?? "")
|
||||
Accessible.checked: segment.selected
|
||||
|
||||
// The selected segment is the only place the prism appears
|
||||
// in a row: blue leads into orchid, never orchid alone.
|
||||
Rectangle {
|
||||
|
||||
@@ -68,6 +68,18 @@ PickerRow {
|
||||
controlWidth: 90
|
||||
divider: index < root.options.length - 1
|
||||
activatable: modelData.value !== root.current
|
||||
|
||||
// An option in an open list, not a button: the reader should say
|
||||
// which one is already chosen. The current option is deliberately
|
||||
// not activatable and so not a Tab stop -- picking what is already
|
||||
// picked is not a thing to walk to.
|
||||
activeFocusOnTab: modelData.value !== root.current
|
||||
|
||||
Accessible.role: Accessible.RadioButton
|
||||
Accessible.name: String(modelData.label ?? "")
|
||||
Accessible.description: String(modelData.detail ?? "")
|
||||
Accessible.checked: modelData.value === root.current
|
||||
|
||||
onActivated: {
|
||||
root.picked(modelData.value);
|
||||
root.collapse();
|
||||
|
||||
@@ -50,6 +50,17 @@ Column {
|
||||
controlWidth: 210
|
||||
onActivated: root.expanded = !root.expanded
|
||||
|
||||
// The collapsed row is already a Tab stop and already activates on
|
||||
// Space through SettingRow; what it cannot say for itself is that it
|
||||
// opens a list, and which value that list is currently sitting on.
|
||||
activeFocusOnTab: root.enabled
|
||||
|
||||
Accessible.role: Accessible.ComboBox
|
||||
Accessible.name: root.label
|
||||
Accessible.description: root.detail === ""
|
||||
? root.value
|
||||
: `${root.detail} — ${root.value}`
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
@@ -335,6 +335,30 @@ erase a user-selected accent or a theme switch to leave the border behind.
|
||||
| `ChoiceRow { setting }` | An enum, as a segmented control |
|
||||
| `ActionRow` | A button: opens a GNOME panel, runs a one-shot |
|
||||
| `TextRow` | A genuinely read-only fact |
|
||||
| `SettingsNote` | An inset paragraph: the explanation too long to be a `detail` |
|
||||
|
||||
`SettingsNote` is deliberately quiet and must stay that way. It explains
|
||||
absences and boundaries — what this session cannot do, and why — and a
|
||||
warning-coloured box would turn "not offered yet, here is the reason" into
|
||||
"something is wrong here".
|
||||
|
||||
### The rows are keyboard-reachable
|
||||
|
||||
Every row primitive carries `Accessible.role`, an `Accessible.name` taken from
|
||||
its label, and an `Accessible.description` taken from its detail; the
|
||||
interactive element sets `activeFocusOnTab` and draws a two-pixel
|
||||
`Theme.accentSecondary` ring **only** while it holds focus. Tab walks the page,
|
||||
Space and Enter flip a switch or press a button, and Left/Right (and Up/Down)
|
||||
step a slider by one schema step or move a segmented choice by one option,
|
||||
never wrapping.
|
||||
|
||||
Two rules hold this together. An inert row is not a Tab stop: `SettingRow`
|
||||
becomes focusable only when `activatable` is true, because Tab landing on
|
||||
static text is how keyboard navigation stops being usable. And a keyboard
|
||||
change commits through exactly the same path a pointer change does —
|
||||
`SliderRow`'s arrows go through the same 140 ms debounce as a drag, so a held
|
||||
arrow behaves like a drag rather than a burst of writes the compositor spends
|
||||
the whole time rejecting.
|
||||
|
||||
`TextRow` is for facts, not for settings that were merely expensive to wire.
|
||||
Before Stage 3 more than half of all rows were static text standing in for
|
||||
|
||||
@@ -18,12 +18,61 @@ SettingRow {
|
||||
|
||||
controlWidth: Math.max(150, root.options.length * 92)
|
||||
|
||||
readonly property var currentOption:
|
||||
root.options.find(option => option.value === root.value) ?? null
|
||||
|
||||
// One segment along, never wrapping -- the same neighbour a click would
|
||||
// have picked, asked for in the same way.
|
||||
function stepChoice(delta: int): void {
|
||||
if (!root.enabled || root.options.length === 0)
|
||||
return;
|
||||
const at = root.options.findIndex(option => option.value === root.value);
|
||||
const from = at < 0 ? 0 : at;
|
||||
const next = Math.max(0, Math.min(root.options.length - 1, from + delta));
|
||||
if (next === at)
|
||||
return;
|
||||
root.selected(root.options[next].value);
|
||||
}
|
||||
|
||||
// Sibling of the strip rather than a child of it: a Rectangle inside a Row
|
||||
// would be laid out as one more segment.
|
||||
Rectangle {
|
||||
anchors.fill: strip
|
||||
anchors.margins: -3
|
||||
radius: 12
|
||||
z: -1
|
||||
color: "transparent"
|
||||
visible: strip.activeFocus
|
||||
border.width: 2
|
||||
border.color: Theme.accentSecondary
|
||||
}
|
||||
|
||||
Row {
|
||||
id: strip
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
opacity: root.enabled ? 1 : 0.45
|
||||
|
||||
activeFocusOnTab: root.enabled
|
||||
|
||||
Accessible.role: Accessible.ComboBox
|
||||
Accessible.name: root.label
|
||||
Accessible.description: {
|
||||
const chosen = root.currentOption
|
||||
? String(root.currentOption.label ?? "")
|
||||
: "nothing selected";
|
||||
return root.detail === "" ? chosen : `${root.detail} — ${chosen}`;
|
||||
}
|
||||
Accessible.focusable: root.enabled
|
||||
Accessible.focused: strip.activeFocus
|
||||
|
||||
Keys.onLeftPressed: root.stepChoice(-1)
|
||||
Keys.onUpPressed: root.stepChoice(-1)
|
||||
Keys.onRightPressed: root.stepChoice(1)
|
||||
Keys.onDownPressed: root.stepChoice(1)
|
||||
|
||||
Repeater {
|
||||
model: root.options
|
||||
|
||||
@@ -45,6 +94,12 @@ SettingRow {
|
||||
? Theme.alpha(Theme.accent, 0.5)
|
||||
: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
// Named individually so the reader says which option it is on.
|
||||
// Not a Tab stop: the strip is one stop and the arrows walk it.
|
||||
Accessible.role: Accessible.RadioButton
|
||||
Accessible.name: String(segment.modelData.label ?? "")
|
||||
Accessible.checked: segment.current
|
||||
|
||||
Text {
|
||||
id: segmentLabel
|
||||
anchors.centerIn: parent
|
||||
|
||||
@@ -18,9 +18,33 @@ Item {
|
||||
property bool activatable: false
|
||||
signal activated
|
||||
|
||||
// Keyboard and screen-reader reach, added without changing a single thing
|
||||
// about how the row looks or behaves under the pointer.
|
||||
//
|
||||
// Only an activatable row becomes a Tab stop. A row that does nothing when
|
||||
// clicked must not collect focus either -- Tab landing on inert text is how
|
||||
// keyboard navigation stops being usable long before it stops working.
|
||||
function activate(): void {
|
||||
if (root.activatable)
|
||||
root.activated();
|
||||
}
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: Math.max(56, copy.implicitHeight + 20)
|
||||
|
||||
activeFocusOnTab: root.activatable
|
||||
|
||||
Accessible.role: root.activatable ? Accessible.Button : Accessible.StaticText
|
||||
Accessible.name: root.label
|
||||
Accessible.description: root.detail
|
||||
Accessible.focusable: root.activatable
|
||||
Accessible.focused: root.activeFocus
|
||||
Accessible.onPressAction: root.activate()
|
||||
|
||||
Keys.onReturnPressed: root.activate()
|
||||
Keys.onEnterPressed: root.activate()
|
||||
Keys.onSpacePressed: root.activate()
|
||||
|
||||
Text {
|
||||
id: iconLabel
|
||||
anchors.left: parent.left
|
||||
@@ -104,6 +128,20 @@ Item {
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
// The focus ring: the hover plate's geometry with a border added, so a row
|
||||
// reached by Tab reads as the same target the pointer would have hit. Drawn
|
||||
// only while this row holds keyboard focus, so nothing changes at rest.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.bottomMargin: 1
|
||||
radius: 8
|
||||
z: -1
|
||||
visible: root.activatable && root.activeFocus
|
||||
color: Theme.alpha(Theme.fg, 0.05)
|
||||
border.width: 2
|
||||
border.color: Theme.accentSecondary
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: rowHover
|
||||
enabled: root.activatable
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// An inset paragraph inside a card: the explanation that is too long to be a
|
||||
// row's `detail` and too important to be left out.
|
||||
//
|
||||
// SettingsNote {
|
||||
// headline: "Sticky, slow and bounce keys are not offered"
|
||||
// body: "Hyprland has no such options..."
|
||||
// }
|
||||
//
|
||||
// Deliberately quiet. This is not a warning banner and must never grow one's
|
||||
// colouring: the things it explains are absences and boundaries, not problems,
|
||||
// and a red-edged box would make "this desktop cannot do that yet" read as
|
||||
// "something is wrong here". It sits darker than the card it is in, which is
|
||||
// the whole visual signal it needs -- a note, set into the surface.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
// The claim, in one line. Said first because a reader who stops after it
|
||||
// still leaves with the answer.
|
||||
property string headline: ""
|
||||
// Why the claim is true. This is where the receipts go.
|
||||
property string body: ""
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: copy.implicitHeight + 22
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.bgDark, 0.5)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
// Read out as one passage rather than as two unrelated fragments.
|
||||
Accessible.role: Accessible.StaticText
|
||||
Accessible.name: root.headline
|
||||
Accessible.description: root.body
|
||||
|
||||
Column {
|
||||
id: copy
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.leftMargin: 13
|
||||
anchors.rightMargin: 13
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.headline !== ""
|
||||
text: root.headline
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.body !== ""
|
||||
text: root.body
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
lineHeight: 1.35
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,22 @@ Item {
|
||||
return root.step < 1 ? Math.round(clamped * 100) / 100 : clamped;
|
||||
}
|
||||
|
||||
// One schema step, from the arrow keys, committed through the same debounce
|
||||
// the pointer drag uses -- holding an arrow reads as a drag rather than as
|
||||
// a burst of writes the compositor would spend the whole time rejecting.
|
||||
function nudge(steps: int): void {
|
||||
const raw = root.shown + steps * root.step;
|
||||
const clamped = Math.max(root.minimum, Math.min(root.maximum, raw));
|
||||
root.pending = root.step < 1 ? Math.round(clamped * 100) / 100 : clamped;
|
||||
commitTimer.restart();
|
||||
}
|
||||
|
||||
// What a screen reader is told the slider is sitting on. Qt 6.11's attached
|
||||
// Accessible type carries no structured value or range, so the reading and
|
||||
// its bounds go in the description, in the row's own display units.
|
||||
readonly property string reading:
|
||||
`${root.display(root.shown)}, ${root.display(root.minimum)} to ${root.display(root.maximum)}`
|
||||
|
||||
function display(value: real): string {
|
||||
if (value === 0 && root.zeroLabel !== "")
|
||||
return root.zeroLabel;
|
||||
@@ -145,6 +161,35 @@ Item {
|
||||
root.pending = root.quantise(ratio);
|
||||
commitTimer.restart();
|
||||
}
|
||||
|
||||
activeFocusOnTab: true
|
||||
|
||||
Accessible.role: Accessible.Slider
|
||||
Accessible.name: root.label
|
||||
Accessible.description: root.detail === ""
|
||||
? root.reading
|
||||
: `${root.detail} — ${root.reading}`
|
||||
Accessible.focusable: true
|
||||
Accessible.focused: slider.activeFocus
|
||||
Accessible.onIncreaseAction: root.nudge(1)
|
||||
Accessible.onDecreaseAction: root.nudge(-1)
|
||||
|
||||
Keys.onLeftPressed: root.nudge(-1)
|
||||
Keys.onDownPressed: root.nudge(-1)
|
||||
Keys.onRightPressed: root.nudge(1)
|
||||
Keys.onUpPressed: root.nudge(1)
|
||||
|
||||
// Drawn only while the slider holds keyboard focus: transparent
|
||||
// fill, so at rest there is nothing here at all.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: -2
|
||||
radius: 9
|
||||
color: "transparent"
|
||||
visible: slider.activeFocus
|
||||
border.width: 2
|
||||
border.color: Theme.accentSecondary
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
|
||||
@@ -17,7 +17,16 @@ SettingRow {
|
||||
|
||||
controlWidth: 54
|
||||
|
||||
// Space and Enter do what a click does: ask for the other state and let
|
||||
// whoever owns that state decide, exactly as the pointer path does.
|
||||
function flip(): void {
|
||||
if (root.enabled)
|
||||
root.toggled(!root.checked);
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: pill
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 44
|
||||
@@ -26,6 +35,31 @@ SettingRow {
|
||||
color: root.checked ? Theme.accent : Theme.alpha(Theme.fg, 0.14)
|
||||
opacity: root.enabled ? 1 : 0.45
|
||||
|
||||
activeFocusOnTab: root.enabled
|
||||
|
||||
Accessible.role: Accessible.CheckBox
|
||||
Accessible.name: root.label
|
||||
Accessible.description: root.detail
|
||||
Accessible.checked: root.checked
|
||||
Accessible.focusable: root.enabled
|
||||
Accessible.focused: pill.activeFocus
|
||||
Accessible.onPressAction: root.flip()
|
||||
|
||||
Keys.onReturnPressed: root.flip()
|
||||
Keys.onEnterPressed: root.flip()
|
||||
Keys.onSpacePressed: root.flip()
|
||||
|
||||
// Focus ring only -- nothing is drawn here at rest.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: -3
|
||||
radius: height / 2
|
||||
color: "transparent"
|
||||
visible: pill.activeFocus
|
||||
border.width: 2
|
||||
border.color: Theme.accentSecondary
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
x: root.checked ? parent.width - width - 3 : 3
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
@@ -24,10 +24,43 @@ SettingRow {
|
||||
detail: root.spec ? root.spec.detail : ""
|
||||
controlWidth: 48
|
||||
|
||||
// Space and Enter write the same value the pointer would, through the same
|
||||
// verified path -- there is no second way to flip a preference here.
|
||||
function flip(): void {
|
||||
SystemSettings.commitPreference(root.setting, !root.checked);
|
||||
}
|
||||
|
||||
SettingsToggle {
|
||||
id: toggle
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: root.checked
|
||||
onToggled: value => SystemSettings.commitPreference(root.setting, value)
|
||||
|
||||
activeFocusOnTab: true
|
||||
|
||||
Accessible.role: Accessible.CheckBox
|
||||
Accessible.name: root.label
|
||||
Accessible.description: root.detail
|
||||
Accessible.checked: root.checked
|
||||
Accessible.focusable: true
|
||||
Accessible.focused: toggle.activeFocus
|
||||
Accessible.onPressAction: root.flip()
|
||||
|
||||
Keys.onReturnPressed: root.flip()
|
||||
Keys.onEnterPressed: root.flip()
|
||||
Keys.onSpacePressed: root.flip()
|
||||
|
||||
// Focus ring only -- the switch keeps its own border at rest.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: -3
|
||||
radius: height / 2
|
||||
color: "transparent"
|
||||
visible: toggle.activeFocus
|
||||
border.width: 2
|
||||
border.color: Theme.accentSecondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ HealthCheckRow 1.0 HealthCheckRow.qml
|
||||
SegmentRow 1.0 SegmentRow.qml
|
||||
SettingRow 1.0 SettingRow.qml
|
||||
SettingsCard 1.0 SettingsCard.qml
|
||||
SettingsNote 1.0 SettingsNote.qml
|
||||
SettingsButton 1.0 SettingsButton.qml
|
||||
SettingsShell 1.0 SettingsShell.qml
|
||||
SettingsSidebar 1.0 SettingsSidebar.qml
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -89,6 +89,15 @@ ShellRoot {
|
||||
Osd {}
|
||||
}
|
||||
|
||||
// The visual bell, on every screen at once -- someone watching the other
|
||||
// monitor is exactly who this is for. Each one stays unmapped until a
|
||||
// bell-eligible notification arrives, and does nothing at all while the
|
||||
// Visual alerts setting is off.
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
VisualBell {}
|
||||
}
|
||||
|
||||
DisplayIdentify {}
|
||||
|
||||
// ── Single-instance overlays ────────────────────────────────────────────
|
||||
@@ -331,6 +340,38 @@ ShellRoot {
|
||||
}
|
||||
}
|
||||
|
||||
// The magnifier keys: SUPER+ALT+= steps in, SUPER+ALT+- steps out,
|
||||
// SUPER+ALT+0 goes back to 1.00 ×.
|
||||
//
|
||||
// They come through the shell rather than setting cursor:zoom_factor on the
|
||||
// compositor directly, so the stored preference, the Magnifier slider and
|
||||
// what is actually on screen are always the same number -- and so there is
|
||||
// an OSD saying what the magnification now is, which matters when the thing
|
||||
// you are looking at is somewhere else entirely.
|
||||
//
|
||||
// A dead shell means dead zoom keys. That is honest: with the shell down
|
||||
// there is no bar, no dock and no OSD either.
|
||||
IpcHandler {
|
||||
target: "accessibility"
|
||||
|
||||
function zoom(direction: string): string { return Accessibility.stepZoom(direction); }
|
||||
|
||||
// Re-probe the screen reader and the accessibility bus. The settings
|
||||
// page calls Accessibility.refreshScreenReader() directly when it
|
||||
// opens; this is the same probe from outside, so `status` below can be
|
||||
// asked for something other than "not looked yet".
|
||||
function refresh(): void { Accessibility.refreshScreenReader(); }
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
magnifierFactor: Accessibility.magnifierFactor,
|
||||
visualAlerts: Settings.visualAlerts,
|
||||
orcaRunning: Accessibility.orcaRunning,
|
||||
accessibilityBusRunning: Accessibility.accessibilityBusRunning
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "activity"
|
||||
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Open Accessibility in Settings.
|
||||
# @vicinae.keywords ["settings", "magnifier", "magnifier follows in steps", "high contrast", "dim inactive windows", "dim amount", "pointer size", "text size"]
|
||||
# @vicinae.keywords ["settings", "magnifier", "magnifier follows in steps", "high contrast", "dim inactive windows", "dim amount", "flash the screen for notifications", "pointer size", "text size", "magnifier zoom", "zoom in and out", "reduce motion", "visual alerts"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page accessibility
|
||||
|
||||
+3
-2
@@ -4,7 +4,7 @@
|
||||
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
|
||||
after changing the schema; a contract fails when this copy is stale.
|
||||
|
||||
173 settings across 36 groups. 77 of them are applied to the compositor and confirmed by reading the value back.
|
||||
174 settings across 36 groups. 77 of them are applied to the compositor and confirmed by reading the value back.
|
||||
|
||||
## accessibility
|
||||
|
||||
@@ -12,11 +12,12 @@ Found on **Accessibility**.
|
||||
|
||||
| Setting | Default | What it does |
|
||||
|---|---|---|
|
||||
| **Magnifier**<br>`magnifierFactor` `cursor:zoom_factor` | 1.0 | Magnifies the screen around the pointer. 1.0 is off. Range 1.0–5.0. |
|
||||
| **Magnifier**<br>`magnifierFactor` `cursor:zoom_factor` | 1.0 × | Magnifies the screen around the pointer. 1.00 × is off. Range 1.0–5.0. |
|
||||
| **Magnifier follows in steps**<br>`magnifierRigid` `cursor:zoom_rigid` | false | Moves the magnified view in increments rather than gliding with the pointer |
|
||||
| **High contrast**<br>`highContrast` | false | Increases contrast in applications that support it. Modern GTK applications read this from the desktop portal and restyle themselves; older ones need a high-contrast theme, which is not installed here. |
|
||||
| **Dim inactive windows**<br>`dimInactive` `decoration:dim_inactive` | false | Darkens every window except the focused one, so the active window is unmistakable |
|
||||
| **Dim amount**<br>`dimStrength` `decoration:dim_strength` | 0.5 | How much darker unfocused windows are. Range 0.05–0.9. |
|
||||
| **Flash the screen for notifications**<br>`visualAlerts` | false | A single flash at the edges of every screen when a notification arrives that would ring the bell |
|
||||
| **Pointer size**<br>`cursorSize` | 24 px | Applies to the compositor and to applications. Range 16–64. |
|
||||
| **Text size**<br>`textScale` | 1.0 | Scales interface text everywhere; 1.00 is the design size. Range 0.75–2.0. |
|
||||
|
||||
|
||||
@@ -1470,3 +1470,205 @@ benefits from.
|
||||
`search-routing-contract`, `schema-hypr-shape-contract`,
|
||||
`preference-schema-contract`, `settings-docs-contract`,
|
||||
`readme-contract`), then the harness ones last.
|
||||
|
||||
## Phase 14 (Accessibility) — append below
|
||||
|
||||
Spec: `2026-08-24-accessibility-redesign.md`. The Accessibility page stopped
|
||||
being six cards of controls sorted by mechanism plus one card that apologised,
|
||||
and became five sorted by what is being accommodated: Vision, Motion, Hearing,
|
||||
Keyboard & pointer, Screen reader. Behind them, the four things an
|
||||
accessibility page exists for and this one did not have: the magnifier gained
|
||||
keybinds routed through the shell, Reduce motion became true of the shell's own
|
||||
bar, dock, panels and OSD rather than only of the compositor, a visual
|
||||
alternative to the notification bell arrived, and Orca became a process that is
|
||||
reported as running or not instead of a button that launched it and forgot.
|
||||
Invisible and largest: the nine shared row primitives gained screen-reader
|
||||
names, roles, Tab stops and key handling, which reaches all fourteen categories
|
||||
at once.
|
||||
|
||||
Three agents edited the tree concurrently; everything below was reconciled
|
||||
against the landed files rather than against the spec's pinned shapes.
|
||||
|
||||
### New contracts (1)
|
||||
|
||||
`quickshell/accessibility-contract`. The README count line moves **174 → 175**;
|
||||
`setup/readme-contract` was run and passes ("175 contracts, as documented").
|
||||
|
||||
Entirely static, on purpose. Every failure it guards against is one a live run
|
||||
would report as working: `hyprctl keyword` exits 0 on a Lua-configured Hyprland
|
||||
while refusing the write, a gsettings `screen-reader-enabled` write succeeds and
|
||||
starts nothing, and a row with no `Accessible.name` looks and behaves exactly
|
||||
like one that has it. Nothing here starts a shell, applies a zoom, starts or
|
||||
stops Orca, or writes a gsettings key.
|
||||
|
||||
What it pins, in the order the file argues them:
|
||||
|
||||
- **The zoom path commits through the preference and never touches `hyprctl`.**
|
||||
Asserted over the PATH rather than over `stepZoom`'s own text: the slice is
|
||||
`stepZoom` plus every function in `Accessibility.qml` it hands work to
|
||||
(`showZoomOsd`, `zoomLabel`), collected transitively, so splitting the OSD
|
||||
call into a helper moves the assertions with it rather than out from under
|
||||
them. Within that path: no `hyprctl`, no `keyword`, a
|
||||
`SystemSettings.commitPreference` call, an `OsdState` post, and clamp bounds
|
||||
that either read `PreferenceSchema.spec` or match the schema's own 1.0–5.0.
|
||||
The `accessibility` IPC handler in `shell.qml` is checked the same way.
|
||||
- **The three binds, matched by target and verb rather than by chord.**
|
||||
`SUPER+=` was already "Reset split", so which free chords the zoom keys take
|
||||
was a decision made against `keybinds.lua` rather than against the mock — a
|
||||
contract naming `SUPER+=` would have been wrong on the day it was written.
|
||||
A separate check fails on any chord bound twice, which is the actual hazard:
|
||||
Hyprland takes the last bind and the earlier action stops working silently.
|
||||
- **The visual bell is one-shot.** No `loops: Animation.Infinite`, no `loops:
|
||||
-1`, no Timer with `repeat: true` anywhere behind it, and it reads
|
||||
`visualAlerts`.
|
||||
- **The flash follows bell eligibility EXCEPT the event-sounds gate.** The
|
||||
signal name is read out of `VisualBell.qml`'s `Connections { target: Notifs }`
|
||||
rather than guessed, which also pins that the two are really wired. Two
|
||||
structural checks then run over `Notifs.qml`: no `SoundFeedback.eventSounds`
|
||||
early return may stand between the enclosing function's first line and the
|
||||
emit, and the emit must sit on a path that DOES carry the shared gates
|
||||
(`appRule(...).sound`, low urgency, `suppress-sound`) — otherwise the flash
|
||||
would not be the bell seen, but a second louder notifier ignoring every
|
||||
per-application rule somebody set. A third check fails if the file stops
|
||||
explaining the exception, since an undocumented exception is one the next
|
||||
reader helpfully removes.
|
||||
- **Orca is a process, never a gsettings key.** `screen-reader-enabled` is
|
||||
grepped for repo-wide across the shell's QML and JS, comment-stripped first.
|
||||
Plus: `orcaRunning` exists, something `pgrep`s, and the probe is not on a
|
||||
repeating Timer — a poll that runs all session to answer a question nobody
|
||||
asked is a cost with no reader.
|
||||
- **The nine primitives.** `Accessible.name`, `Accessible.role` and
|
||||
`activeFocusOnTab` in each of `SliderRow`, `ToggleRow`, `SwitchRow`,
|
||||
`ActionRow`, `ChoiceRow`, `SegmentRow`, `SettingRow`, `OptionPickerRow`,
|
||||
`PickerRow`, resolved through the QML inheritance chain (a name inherited
|
||||
from `SettingRow` is a name the row really has) — with one exception that is
|
||||
NOT inheritable: a file drawing its own `MouseArea`/`SettingsToggle`/
|
||||
`SettingsButton`/`ValueSlider` must declare its own `activeFocusOnTab`,
|
||||
because `SettingRow` is a Tab stop only when `activatable`, which a
|
||||
`ToggleRow` is not. Then: Space/Enter activation on the four rows that
|
||||
activate, Left/Right on the two that hold a range, a focus indicator gated on
|
||||
`activeFocus`, and — the visual-at-rest promise — no focus border painted
|
||||
unconditionally, since these rows are used by every page.
|
||||
- **The page, per the approved mock.** The five card titles, the five retired
|
||||
ones absent, `dimStrength` gated on `dimInactive`, the mono-audio row present
|
||||
and marked not-yet with somewhere to go meanwhile, the Keyboard jump going
|
||||
through `openSettings()` rather than assigning `settingsPage` by hand, and no
|
||||
`Process` on the page or its own components.
|
||||
- **The honesty box is a statement, not an alarm.** It must name Hyprland (not
|
||||
"Wayland"), say the absence was probed rather than assumed, and explain why
|
||||
the GNOME switches are inert here — and it must NOT be painted in
|
||||
`Theme.danger`/`Theme.warn` or use the words Warning, Error, Unsupported,
|
||||
unfortunately, sorry, Broken. An accessibility page is the last page that can
|
||||
afford to open with an alarm.
|
||||
- **The dead zero label, swept across every settings page rather than only this
|
||||
one.** `SliderRow.display()` substitutes `zeroLabel` at exactly 0, so a row
|
||||
setting one for a setting whose schema minimum is above 0 is copy that can
|
||||
never appear — which is what `zeroLabel: "Off"` was doing under a magnifier
|
||||
whose minimum IS 1.0. Any page reintroducing that class of bug now fails.
|
||||
- **Reduce motion is true of the shell**: `Theme.motionEnabled` reads
|
||||
`Settings.animationsEnabled`, and all four duration tokens collapse to 0.
|
||||
- **The cross-goal collision, which is silent.** `VisualBell` must not time its
|
||||
flash with a `Theme.dur*` token: those now collapse to 0 under Reduce motion,
|
||||
so a flash timed with `Theme.durFast` would be instantaneous and therefore
|
||||
invisible — switching on Reduce motion would silently switch OFF Visual
|
||||
alerts, for somebody quite likely to want both. A landed and pinned.
|
||||
|
||||
### Reconciled
|
||||
|
||||
- **`quickshell/notification-app-rules-contract` — RUN, PASS after a fix.** It
|
||||
broke on the visual-alerts refactor: the bell's shared gates moved out of
|
||||
`playBell` into the new `bellWouldRing` predicate, and the contract read only
|
||||
`playBell`'s literal body, so it reported that the per-application sound
|
||||
switch and the effective-urgency check had been dropped when both had merely
|
||||
moved one call up. Now asserted over the bell's DECISION PATH (`playBell` +
|
||||
`bellWouldRing`), plus a new assertion that `playBell` actually consults the
|
||||
predicate — without which every gate would sit in a function nothing runs,
|
||||
which reads exactly like a passing contract.
|
||||
- **`quickshell/osd-model-contract` — RUN, PASS.** Gained the magnifier's OSD
|
||||
kind: `iconFor('zoom', 0.4)` is `zoom-in-symbolic` and `iconFor('zoom', 0)` is
|
||||
`zoom-original-symbolic`, since an empty bar is 1.00 ×, which is off rather
|
||||
than barely magnified. Without a mapping the kind falls through to its own
|
||||
name and the OSD draws the generic fallback glyph — for the one shortcut
|
||||
whose whole job is telling somebody who cannot read the screen what the
|
||||
magnification now is.
|
||||
- **No contract pinned the Theme duration literals.** Checked by grep across
|
||||
`tests/` for `durFast`/`durNormal`/`durSlow`/`durDockReveal` and
|
||||
`motionEnabled`: zero hits before the change, so gating them broke nothing.
|
||||
`accessibility-contract` now pins the gating itself, which is the first time
|
||||
those tokens have been pinned anywhere.
|
||||
- **`quickshell/settings-ownership-contract` — RUN, PASS unchanged.** Its
|
||||
duplicate table expects exactly `animationsEnabled`, `cursorInactiveTimeout`,
|
||||
`cursorSize` and `inactiveOpacity` to appear on two pages, and the rebuilt
|
||||
page keeps all four, so the mirror set did not move.
|
||||
|
||||
### Seven search entries added
|
||||
|
||||
Magnifier zoom, Zoom in and out, Reduce motion, Visual alerts, Screen reader,
|
||||
Orca, Sticky keys — all routing to `accessibility`. The schema already covers
|
||||
the switches by their own labels, so these are only the words people arrive
|
||||
with that no label uses: the verb rather than the noun ("zoom in", not
|
||||
"Magnifier"), the application's name rather than its category ("Orca", not
|
||||
"Screen reader"), and the schema's own wording gap ("Reduce motion", where the
|
||||
label reads "Animations"; "Visual alerts", where it reads "Flash the screen for
|
||||
notifications").
|
||||
|
||||
**High contrast was deliberately NOT added**, and the contract fails if it is:
|
||||
it is already a schema label, and the index covers every schema label, so a
|
||||
second copy would list the same setting twice in one result. The contract also
|
||||
fails if any of these entries routes anywhere but `accessibility`.
|
||||
|
||||
### Schema docs and launcher commands regenerated
|
||||
|
||||
`quickshell/scripts/panama-settings-docs` and `panama-settings-commands` both
|
||||
run without `--check` and their output committed. `docs/settings.md` goes 173 →
|
||||
**174 settings across 36 groups**: `visualAlerts` renders in the accessibility
|
||||
group, and `magnifierFactor` picks up its new `×` unit in both the default
|
||||
column ("1.0 ×") and the detail ("1.00 × is off"). `settings-accessibility`'s
|
||||
Vicinae keywords gained "flash the screen for notifications", "magnifier zoom",
|
||||
"zoom in and out", "reduce motion" and "visual alerts".
|
||||
`quickshell/settings-docs-contract` — RUN, PASS (174 settings documented);
|
||||
`quickshell/panama-commands-contract` — RUN, PASS (77 commands; the
|
||||
settings generator writes 38 of them).
|
||||
|
||||
### Deferred, and why
|
||||
|
||||
- **`quickshell/settings-pages-contract`, `settings-write-sweep-contract`,
|
||||
`settings-search-contract`, `settings-buttons-contract` (live half),
|
||||
`settings-preferences-contract`, `settings-commit-reset-contract`,
|
||||
`settings-system-contract`, `settings-hyprland-write-contract` — NOT RUN**:
|
||||
they daemonize a Quickshell harness or drive the live settings window. The
|
||||
Accessibility page was rebuilt this phase, `SettingsNote.qml` is new, and all
|
||||
nine shared row primitives changed, so this set is the end-of-redesign sweep
|
||||
and matters more here than it did in any previous phase — a broken
|
||||
`SliderRow` takes every settings page down, and only these would see it.
|
||||
`qmldir-registration-contract` (static) was run and passes, so
|
||||
`SettingsNote` at least resolves.
|
||||
- **The whole live half of the zoom keybinds.** Nothing here presses a key. The
|
||||
contract proves the binds exist, name the IPC verb, sit on unoccupied chords,
|
||||
and that the path behind them commits through the preference; whether
|
||||
Hyprland picks the new binds up needs a `hyprctl reload`, which this phase
|
||||
deliberately did not run.
|
||||
- **The flash has not been seen.** `VisualBell` is pinned structurally — one
|
||||
animation, no loops, no repeating timer, gated on the preference, not timed
|
||||
with a motion-gated token — but nobody has watched a notification arrive with
|
||||
Visual alerts on. That is the one assertion here a screenshot would settle
|
||||
and a grep cannot.
|
||||
- **Orca has not been started or stopped.** The service is pinned as a process
|
||||
probe with on-demand polling; whether `pgrep -x orca` matches the installed
|
||||
Orca is A's verified claim in a comment (it calls `set_process_name("orca")`,
|
||||
which renames `comm`), not something this contract re-checks.
|
||||
- **The screen reader has not read the page.** `Accessible.name`, `role` and
|
||||
`activeFocusOnTab` are present in all nine files and the key handlers are
|
||||
there, but no AT-SPI client has walked the settings window to confirm the
|
||||
names come out in a useful order. That needs Orca running against a live
|
||||
shell.
|
||||
- **`quickshell/sound-page-contract` fails, and it is not this work.** Verified
|
||||
by stashing the whole working tree and running it against a clean checkout:
|
||||
it fails identically ("DictationPage does not report whether the speech
|
||||
server is installed"), so it was already red before this phase began.
|
||||
- Run order for this phase: the static ones first (`accessibility-contract`,
|
||||
`osd-model-contract`, `preference-schema-contract`,
|
||||
`schema-hypr-shape-contract`, `settings-ownership-contract`,
|
||||
`search-routing-contract`, `ipc-targets-contract`, `settings-jump-contract`,
|
||||
`settings-docs-contract`, `readme-contract`, all eight of `tests/hypr/`),
|
||||
then the sandboxed-harness ones, then the live settings window last.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Accessibility redesign — everything that's real
|
||||
|
||||
Approved mock: `home-mocks/accessibility.html` (scratchpad, :8642). Spec wins over mock on
|
||||
conflict. Tabless leaf stays.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Magnifier keybinds**: Super+= / Super+- / Super+0 step the zoom from anywhere, through the
|
||||
shell so the stored preference stays truthful and the OSD shows the level. Mechanism: the
|
||||
binds call `qs ipc call accessibility zoom in|out|reset`; a new IPC target routes to
|
||||
`Accessibility.stepZoom()`, which computes (×1.25 steps, clamp to the schema's 1.0–5.0),
|
||||
commits through the normal verified-preference path, and posts the OSD. No new script; a
|
||||
dead shell means dead zoom keys, which is honest (everything else in the shell is dead too).
|
||||
2. **Reduce motion becomes true**: `Theme.qml`'s `dur*` tokens gate on
|
||||
`Settings.animationsEnabled` (0 when off) so the bar/dock/OSD/panels genuinely still.
|
||||
3. **Visual alerts**: new schema key `visualAlerts` (bool, def false, group `accessibility`);
|
||||
when on, a one-shot screen-edge flash (per-screen overlay, single animation per event,
|
||||
never looping) fires wherever `Notifs.playBell` decides a notification sounds (including
|
||||
when the audible bell is skipped for `sound: false` apps? No — the flash follows the same
|
||||
eligibility as the bell EXCEPT the eventSounds gate: visual alerts are for people who can't
|
||||
hear the bell, so the flash fires on bell-eligible notifications even when event sounds are
|
||||
off. Pin that.)
|
||||
4. **Honest Orca control**: running state (pgrep poll on page open + after actions),
|
||||
Start/Stop; the a11y-bus readiness line. No gsettings screen-reader toggle (gsd-owned,
|
||||
inert — refused).
|
||||
5. **The primitives get accessible**: `SliderRow`, `ToggleRow`, `SwitchRow`, `ActionRow`,
|
||||
`ChoiceRow`, `SegmentRow`, `SettingRow` (activatable), `OptionPickerRow`, `PickerRow`
|
||||
gain `Accessible.role`/`Accessible.name` (from label; detail as description),
|
||||
`activeFocusOnTab` on the interactive element, key handling (Space/Enter activates,
|
||||
Left/Right steps sliders and segments), and a visible focus indicator (the titlebar
|
||||
close-button border pattern). No API or visual-at-rest changes.
|
||||
6. **Page rebuilt** per mock: Vision / Motion / Hearing / Keyboard & pointer / Screen reader;
|
||||
`dimStrength` gated on `dimInactive`; magnifier "1× is off" honesty (schema gains
|
||||
`unit: "×"`-style display via the page detail — fix the dead `zeroLabel`); the honesty box
|
||||
for sticky/slow/bounce with the probed-not-assumed copy; mono-audio NOT YET row; "Open
|
||||
Keyboard" jump.
|
||||
7. Cleanups: `DESKTOP-PARITY.md`'s stale AccessX claim corrected; search entries for
|
||||
magnifier/zoom/orca/screen reader/high contrast/reduce motion/visual alerts.
|
||||
|
||||
Non-goals: mono audio (deferred with the on-page honesty), sticky/slow/bounce/hover/mouse
|
||||
keys (refused with receipts), gsettings `enable-animations`/`screen-reader-enabled` writes
|
||||
(inert), shell text scaling.
|
||||
|
||||
## Ownership
|
||||
|
||||
- **A**: `config/PreferenceSchema.qml` (visualAlerts; magnifierFactor detail/unit fix),
|
||||
`config/dot/hypr/keybinds.lua` (three zoom binds — CHECK for collisions with existing
|
||||
Super+=/-/0 binds first; pick free chords and update the mock copy via B if needed),
|
||||
`shell.qml` (accessibility IPC target), `services/Accessibility.qml` (stepZoom, orcaRunning
|
||||
+ start/stop, poll discipline), `services/Notifs.qml` (flash trigger signal per goal 3),
|
||||
NEW `modules/notifications/VisualBell.qml` (+ per-screen wiring in shell.qml),
|
||||
`config/dot/hypr/DESKTOP-PARITY.md` (stale claim), `services/OsdState.qml`/`OsdModel.js`
|
||||
only if the zoom OSD needs a new kind (coordinate with C on osd-model-contract).
|
||||
- **B**: the nine row primitives (accessibility additions only — zero API/visual-at-rest
|
||||
change), `modules/settings/AccessibilityPage.qml` rebuild (+ any new components + qmldir).
|
||||
Reuse KeycapChord for the chord display.
|
||||
- **C**: `services/SettingsSearch.qml`, NEW `tests/quickshell/accessibility-contract` (page
|
||||
structure; zoom IPC path commits through the verified-preference path and never calls
|
||||
hyprctl keyword; visual bell one-shot — no `loops: Animation.Infinite`, no Timer-driven
|
||||
repeat; flash follows bell eligibility but not the eventSounds gate; primitives carry
|
||||
Accessible.name + activeFocusOnTab — sweep all nine files; Orca control uses process state,
|
||||
never gsettings), `osd-model-contract` (zoom kind if added), reconcile any contract pinning
|
||||
Theme durations or the primitives' file contents, backlog Phase 14, README count
|
||||
(174 → 175 expected), docs/commands regen at the end (schema changed).
|
||||
|
||||
Hard rules: no live mutations (no zoom application, no orca start/stop, no gsettings writes,
|
||||
no hyprctl keyword/reload); hermetic stubs only; valid QML at every save (primitives are used
|
||||
by EVERY page — a broken SliderRow takes the whole settings app down, so B edits them one at
|
||||
a time with a reload-check between each). B programs against A's pinned APIs; A updates this
|
||||
spec before changing them.
|
||||
Executable
+889
@@ -0,0 +1,889 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Accessibility: every switch on the page does something, and the things that
|
||||
# make it usable without a pointer are not decorations.
|
||||
#
|
||||
# The old page was six cards of controls that mostly worked and one card that
|
||||
# apologised. What it did not have was any of the machinery an accessibility
|
||||
# page exists for: the magnifier had no shortcut, so zooming meant opening
|
||||
# Settings with a screen you could not read; "Reduce motion" stilled the
|
||||
# compositor while the shell's own bar, dock and panels went on sliding; there
|
||||
# was no visual alternative to the notification bell at all; and every shared
|
||||
# settings row -- the rows that make up all fourteen categories -- was invisible
|
||||
# to a screen reader and unreachable by Tab.
|
||||
#
|
||||
# Five properties are pinned here, in descending order of what it would cost to
|
||||
# get them wrong:
|
||||
#
|
||||
# 1. THE ZOOM SHORTCUT GOES THROUGH THE STORED PREFERENCE. The obvious
|
||||
# implementation is `hyprctl keyword cursor:zoom_factor 1.25`, and on this
|
||||
# Lua-configured Hyprland that write is REFUSED while exiting 0 (see
|
||||
# SystemSettings.qml's own note). The zoom would appear to work in testing
|
||||
# and do nothing on the machine, and even where it did work the stored
|
||||
# preference would be a lie -- the slider on this page would read 1.00 for
|
||||
# a screen that is magnified fourfold. The bind therefore commits through
|
||||
# SystemSettings.commitPreference like every other preference.
|
||||
# 2. The visual bell flashes ONCE, and it flashes for people who cannot hear.
|
||||
# A looping flash on a screen is not an alert, it is a hazard -- it is the
|
||||
# exact stimulus photosensitive-epilepsy guidance exists about -- so no
|
||||
# infinite animation and no repeating timer may sit behind it. And it is
|
||||
# deliberately NOT gated on the event-sounds switch: a visual alert is for
|
||||
# somebody who turned sounds off, or cannot hear them, and gating it on
|
||||
# sound would make it fire only for people who did not need it.
|
||||
# 3. Orca is controlled as a process, never as a gsettings key. GNOME's
|
||||
# `screen-reader-enabled` is applied by gnome-settings-daemon, which does
|
||||
# not run in this session: writing it stores a preference that starts
|
||||
# nothing, which is the single most costly kind of lie an accessibility
|
||||
# page can tell.
|
||||
# 4. The nine shared row primitives carry a screen-reader name, a role, and a
|
||||
# Tab stop. These rows ARE the settings application; getting this right
|
||||
# once is what makes every page reachable, and losing it in one file
|
||||
# silently removes a whole class of control from the keyboard.
|
||||
# 5. Reduce motion is true of the shell itself, not only of the compositor.
|
||||
#
|
||||
# Entirely static. Nothing here starts a shell, applies a zoom, starts or stops
|
||||
# Orca, or writes a gsettings key -- which is also the point: the failures this
|
||||
# guards against are ones a live run would report as working.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
shell_dir="$repo_dir/config/dot/quickshell"
|
||||
settings="$shell_dir/modules/settings"
|
||||
page="$settings/AccessibilityPage.qml"
|
||||
qmldir="$settings/qmldir"
|
||||
schema="$shell_dir/config/PreferenceSchema.qml"
|
||||
theme="$shell_dir/config/Theme.qml"
|
||||
shell_qml="$shell_dir/shell.qml"
|
||||
service="$shell_dir/services/Accessibility.qml"
|
||||
notifs="$shell_dir/services/Notifs.qml"
|
||||
visual_bell="$shell_dir/modules/notifications/VisualBell.qml"
|
||||
osd_model="$shell_dir/modules/osd/OsdModel.js"
|
||||
search="$shell_dir/services/SettingsSearch.qml"
|
||||
keybinds="$repo_dir/config/dot/hypr/keybinds.lua"
|
||||
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
for file in "$page" "$schema" "$theme" "$shell_qml" "$service" "$notifs" \
|
||||
"$osd_model" "$search" "$keybinds" "$qmldir"; do
|
||||
[[ -r "$file" ]] || { printf 'accessibility contract: missing %s\n' "${file#"$repo_dir/"}" >&2; exit 1; }
|
||||
done
|
||||
|
||||
# The page and the components only it uses, as one surface. A card lifted into
|
||||
# a component of its own is a normal thing to do while building this, and every
|
||||
# assertion below about what the page shows would quietly stop meaning anything
|
||||
# if it only ever read AccessibilityPage.qml. Shared rows are excluded by the
|
||||
# same test that finds these: a component another settings page also
|
||||
# instantiates is not part of this page's own structure.
|
||||
mapfile -t page_files < <(python3 - "$page" "$settings" <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
page, settings = sys.argv[1], sys.argv[2]
|
||||
source = open(page, encoding="utf-8").read()
|
||||
others = [os.path.join(settings, name) for name in os.listdir(settings)
|
||||
if name.endswith(".qml") and os.path.join(settings, name) != page]
|
||||
other_text = "\n".join(open(path, encoding="utf-8").read() for path in others)
|
||||
|
||||
files = [page]
|
||||
for name in sorted(set(re.findall(r"\b([A-Z][A-Za-z0-9]+) \{", source))):
|
||||
candidate = os.path.join(settings, name + ".qml")
|
||||
if not os.path.exists(candidate):
|
||||
continue
|
||||
if re.search(r"\b" + name + r" \{", other_text):
|
||||
continue
|
||||
files.append(candidate)
|
||||
print("\n".join(files))
|
||||
PY
|
||||
)
|
||||
|
||||
page_has() { grep -Fq "$@" "${page_files[@]}"; }
|
||||
page_matches() { grep -Eq "$@" "${page_files[@]}"; }
|
||||
|
||||
# Comment-stripped page text, for the assertions that must not be satisfied by
|
||||
# prose. This file and the page both discuss the things they deliberately do
|
||||
# not do, and a contract that read a comment as an implementation would pass on
|
||||
# a page that only talked about working.
|
||||
page_code="$(sed -E 's://.*::' "${page_files[@]}")"
|
||||
|
||||
# ── 1. The zoom shortcut ─────────────────────────────────────────────────────
|
||||
|
||||
# The IPC target the binds call, and the verb they call on it.
|
||||
zoom_handler="$(python3 - "$shell_qml" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = open(sys.argv[1], encoding="utf-8").read()
|
||||
match = re.search(r'IpcHandler \{\s*\n\s+target: "accessibility"(?P<body>.*?)\n \}',
|
||||
source, re.S)
|
||||
print(match.group("body") if match else "")
|
||||
PY
|
||||
)"
|
||||
|
||||
if [[ -z "$zoom_handler" ]]; then
|
||||
note 'shell.qml declares no "accessibility" IPC target, so the zoom keybinds call nothing'
|
||||
else
|
||||
grep -qE 'function zoom\(' <<<"$zoom_handler" \
|
||||
|| note 'the accessibility IPC target has no zoom verb, so `qs ipc call accessibility zoom in` fails'
|
||||
grep -q 'Accessibility\.' <<<"$zoom_handler" \
|
||||
|| note 'the accessibility IPC handler does not route to the Accessibility service, so the zoom logic lives in shell.qml where nothing else can reach it'
|
||||
|
||||
# THE assertion, from the shell's side. A handler that reaches for hyprctl
|
||||
# itself has bypassed both the store and the verified write.
|
||||
grep -qE 'hyprctl' <<<"$(sed -E 's://.*::' <<<"$zoom_handler")" \
|
||||
&& note 'the accessibility IPC handler runs hyprctl directly -- the zoom must commit through the preference, or the slider on the page will disagree with the screen'
|
||||
fi
|
||||
|
||||
# The service function the handler calls, sliced by brace depth from its
|
||||
# signature so the assertions below are about the zoom path and not about the
|
||||
# rest of the file.
|
||||
step_zoom="$(python3 - "$service" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
|
||||
|
||||
|
||||
def body_of(name):
|
||||
"""One function's source, sliced by brace depth from its signature."""
|
||||
for index, line in enumerate(lines):
|
||||
if not re.search(rf"\bfunction {re.escape(name)}\s*\(", line):
|
||||
continue
|
||||
depth = 0
|
||||
body = []
|
||||
for current in lines[index:]:
|
||||
body.append(current)
|
||||
depth += current.count("{") - current.count("}")
|
||||
if depth == 0 and len(body) > 1:
|
||||
break
|
||||
return "\n".join(body)
|
||||
return ""
|
||||
|
||||
|
||||
# stepZoom plus everything in this file it hands the work to. Splitting the OSD
|
||||
# call or the commit into a helper is a normal thing to do, and the assertions
|
||||
# below are about the zoom PATH rather than about one function's text.
|
||||
seen = []
|
||||
queue = ["stepZoom"]
|
||||
collected = []
|
||||
while queue:
|
||||
name = queue.pop(0)
|
||||
if name in seen:
|
||||
continue
|
||||
seen.append(name)
|
||||
body = body_of(name)
|
||||
if not body:
|
||||
continue
|
||||
collected.append(body)
|
||||
for call in re.findall(r"\broot\.(\w+)\s*\(", body):
|
||||
if call not in seen:
|
||||
queue.append(call)
|
||||
|
||||
if collected:
|
||||
print("\n".join(collected))
|
||||
PY
|
||||
)"
|
||||
|
||||
if [[ -z "$step_zoom" ]]; then
|
||||
note 'services/Accessibility.qml has no stepZoom(), so there is nothing behind the zoom IPC'
|
||||
else
|
||||
step_code="$(sed -E 's://.*::' <<<"$step_zoom")"
|
||||
|
||||
# THE assertion. `hyprctl keyword` is refused outright by a Lua-configured
|
||||
# Hyprland while exiting 0, and even a working direct write would leave the
|
||||
# stored magnifierFactor saying the screen is not magnified.
|
||||
grep -qE '\bhyprctl\b' <<<"$step_code" \
|
||||
&& note 'the zoom path shells out to hyprctl -- the zoom must go through SystemSettings.commitPreference, which is the only path that both stores the value and verifies the write'
|
||||
grep -qE 'keyword' <<<"$step_code" \
|
||||
&& note 'the zoom path names the hyprctl keyword verb, which this codebase does not use: a Lua-configured Hyprland refuses that write and exits 0 anyway'
|
||||
|
||||
grep -q 'SystemSettings\.commitPreference' <<<"$step_code" \
|
||||
|| note 'the zoom path does not commit through SystemSettings.commitPreference, so the magnifier slider and the actual magnification can disagree'
|
||||
grep -q 'magnifierFactor' <<<"$step_code" \
|
||||
|| note 'the zoom path does not name the magnifierFactor preference, so whatever it changes is not the setting this page shows'
|
||||
|
||||
# The OSD is the whole reason the bind goes through the shell rather than
|
||||
# being a hyprctl one-liner: somebody who cannot read the screen needs to
|
||||
# be told what the zoom level now is.
|
||||
grep -qE 'OsdState\.' <<<"$step_code" \
|
||||
|| note 'the zoom path posts no OSD, which removes the only reason to route the zoom keys through the shell at all'
|
||||
|
||||
# Bounds come from the schema, or match it. A hardcoded clamp that drifts
|
||||
# from the schema means the keys stop at one number and the slider at
|
||||
# another.
|
||||
step_body="$(mktemp /tmp/panama-a11y-zoom.XXXXXX)"
|
||||
printf '%s\n' "$step_code" >"$step_body"
|
||||
python3 - "$schema" "$step_body" <<'PY' || note 'the zoom path clamps the zoom to numbers that are not the schema range for magnifierFactor, so the keys and the slider stop at different magnifications'
|
||||
import re
|
||||
import sys
|
||||
|
||||
schema = open(sys.argv[1], encoding="utf-8").read()
|
||||
body = open(sys.argv[2], encoding="utf-8").read()
|
||||
|
||||
match = re.search(r'\{\s*\n\s+key: "magnifierFactor".*?\n\s{8}\}', schema, re.S)
|
||||
if not match:
|
||||
raise SystemExit(1)
|
||||
entry = match.group(0)
|
||||
bounds = {name: float(re.search(rf'\b{name}: ([0-9.]+)', entry).group(1))
|
||||
for name in ("min", "max")}
|
||||
|
||||
# Reading the spec is the better answer and passes outright.
|
||||
if "PreferenceSchema" in body or ".spec(" in body or "root.spec" in body:
|
||||
raise SystemExit(0)
|
||||
|
||||
numbers = {float(value) for value in re.findall(r'\b\d+\.\d+\b|\b\d+\b', body)}
|
||||
raise SystemExit(0 if bounds["min"] in numbers and bounds["max"] in numbers else 1)
|
||||
PY
|
||||
rm -f "$step_body"
|
||||
fi
|
||||
|
||||
# The binds themselves: three of them, each naming the IPC verb, and none of
|
||||
# them sitting on a chord that is already answering to something else.
|
||||
#
|
||||
# The chords are NOT pinned by name. Super+= was already "Reset split" when
|
||||
# this was designed, so which free chords the zoom keys take is a decision made
|
||||
# against the file rather than against the mock, and a contract that insisted
|
||||
# on Super+= would have been wrong on the day it was written.
|
||||
#
|
||||
# Matched on the target and verb rather than on how the command string is
|
||||
# built: `qs("accessibility", "zoom in")` and a literal `qs ipc call
|
||||
# accessibility zoom in` are the same bind, and which one keybinds.lua uses is
|
||||
# a style question.
|
||||
bind_lines="$(grep -E 'bind\(' "$keybinds" | tr -s ' ')"
|
||||
zoom_binds="$(grep -cE '"accessibility"[^)]*zoom|accessibility zoom' <<<"$bind_lines")"
|
||||
if (( zoom_binds < 3 )); then
|
||||
note "keybinds.lua has $zoom_binds zoom bind(s) calling the accessibility IPC, expected three (in, out, reset)"
|
||||
fi
|
||||
for verb in in out reset; do
|
||||
grep -qE "(\"accessibility\"[^)]*zoom ${verb}|accessibility zoom ${verb})\b" <<<"$bind_lines" \
|
||||
|| note "no keybind calls the accessibility zoom \"$verb\" verb"
|
||||
done
|
||||
|
||||
# A bind sitting on an occupied chord is silently one bind: Hyprland takes the
|
||||
# last one and the earlier action stops working, which is how a magnifier
|
||||
# shortcut removes "Reset split" without anybody noticing.
|
||||
python3 - "$keybinds" <<'PY' || note 'a chord in keybinds.lua is bound twice, so one of the two actions is unreachable'
|
||||
import re
|
||||
import sys
|
||||
|
||||
seen = {}
|
||||
duplicates = []
|
||||
for line in open(sys.argv[1], encoding="utf-8"):
|
||||
match = re.match(r'\s*bind\("([^"]+)"', line)
|
||||
if not match:
|
||||
continue
|
||||
chord = match.group(1).strip().lower()
|
||||
if chord in seen:
|
||||
duplicates.append(chord)
|
||||
seen[chord] = True
|
||||
if duplicates:
|
||||
print("duplicate chords: " + ", ".join(sorted(set(duplicates))), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
|
||||
# Whatever kind the zoom OSD posts, the OSD has to be able to draw it. An
|
||||
# unrecognised kind falls through OsdModel.iconFor to the kind's own name,
|
||||
# which is not an icon and renders as the fallback glyph.
|
||||
if [[ -n "$step_zoom" ]] && command -v node >/dev/null 2>&1; then
|
||||
kind="$(grep -oE 'OsdState\.(progress|message)\(\s*"[a-z-]+"' <<<"$step_zoom" \
|
||||
| head -1 | grep -oE '"[a-z-]+"' | tr -d '"')"
|
||||
if [[ -n "$kind" ]]; then
|
||||
node - "$osd_model" "$kind" <<'JS' || note "OsdModel has no icon for the \"$kind\" kind the zoom OSD posts, so the magnifier OSD draws the generic fallback"
|
||||
const model = require(process.argv[2])
|
||||
const kind = process.argv[3]
|
||||
const icon = model.iconFor(kind, 0.5)
|
||||
process.exit(icon !== kind && /-symbolic$/.test(icon) ? 0 : 1)
|
||||
JS
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 2. The visual bell ───────────────────────────────────────────────────────
|
||||
|
||||
if [[ ! -r "$visual_bell" ]]; then
|
||||
note 'modules/notifications/VisualBell.qml does not exist, so the visual alerts switch has nothing behind it'
|
||||
else
|
||||
bell_code="$(sed -E 's://.*::' "$visual_bell")"
|
||||
|
||||
# THE assertion. A screen that keeps flashing is not an alert.
|
||||
grep -qE 'loops:\s*(Animation\.Infinite|-1)' <<<"$bell_code" \
|
||||
&& note 'the visual bell loops forever -- a repeating full-screen flash is the stimulus photosensitivity guidance exists about, and it must fire once per notification'
|
||||
python3 - "$visual_bell" <<'PY' || note 'the visual bell is driven by a repeating Timer, so the flash restarts on its own rather than answering one notification'
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = re.sub(r"//.*", "", open(sys.argv[1], encoding="utf-8").read())
|
||||
for match in re.finditer(r"Timer \{", source):
|
||||
depth = 0
|
||||
body = []
|
||||
for char in source[match.start():]:
|
||||
body.append(char)
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
block = "".join(body)
|
||||
if re.search(r"\brepeat:\s*true", block):
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
|
||||
grep -q 'visualAlerts' <<<"$bell_code" \
|
||||
|| note 'the visual bell does not read the visualAlerts preference, so the switch on the page does not turn it off'
|
||||
|
||||
# It draws, it does not act. A flash overlay that shells out has become
|
||||
# something other than an overlay.
|
||||
grep -qE '\bProcess\b' <<<"$bell_code" \
|
||||
&& note 'VisualBell shells out; an alert overlay draws and nothing else'
|
||||
|
||||
# The two halves of this redesign collide here, and the collision is silent.
|
||||
# Theme's duration tokens now collapse to 0 under Reduce motion, so a flash
|
||||
# timed with Theme.durFast would last no time at all -- switching on Reduce
|
||||
# motion would switch OFF Visual alerts, for somebody quite likely to want
|
||||
# both. The flash is information rather than decoration, so it keeps its own
|
||||
# timings.
|
||||
grep -qE 'Theme\.dur' <<<"$bell_code" \
|
||||
&& note 'the visual bell is timed with a Theme.dur token, which collapses to 0 under Reduce motion -- turning on Reduce motion would silently turn off Visual alerts'
|
||||
fi
|
||||
|
||||
# The eligibility rule, which is the subtle half. The flash follows the bell
|
||||
# EXCEPT for the event-sounds gate: somebody who turned sounds off is precisely
|
||||
# who the flash is for.
|
||||
# Which signal, read from the overlay that listens rather than guessed by name:
|
||||
# `function onBellEligible` in VisualBell means the signal is `bellEligible`.
|
||||
# Deriving it from the consumer also pins that the two are really wired, which
|
||||
# a grep for a name of this contract's choosing would not.
|
||||
flash_signal=""
|
||||
if [[ -r "$visual_bell" ]]; then
|
||||
flash_signal="$(python3 - "$visual_bell" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = re.sub(r"//.*", "", open(sys.argv[1], encoding="utf-8").read())
|
||||
match = re.search(r"Connections \{[^}]*?target: Notifs(?P<body>.*?)\n \}", source, re.S)
|
||||
if match:
|
||||
handler = re.search(r"function on([A-Z]\w*)\s*\(", match.group("body"))
|
||||
if handler:
|
||||
name = handler.group(1)
|
||||
print(name[0].lower() + name[1:])
|
||||
PY
|
||||
)"
|
||||
fi
|
||||
if [[ -z "$flash_signal" ]]; then
|
||||
flash_signal="$(grep -oE 'signal +[A-Za-z]*([Ff]lash|[Vv]isualAlert|[Bb]ellEligible)[A-Za-z]*' "$notifs" \
|
||||
| head -1 | awk '{print $2}' | sed -E 's/\(.*//')"
|
||||
fi
|
||||
|
||||
if [[ -z "$flash_signal" ]]; then
|
||||
note 'services/Notifs.qml declares no flash signal, so nothing tells the visual bell a notification arrived'
|
||||
else
|
||||
python3 - "$notifs" "$flash_signal" <<'PY' || note "the $flash_signal signal is emitted after the SoundFeedback.eventSounds guard, so the visual alert only fires for people whose sounds are already on -- exactly the people who do not need it"
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = open(sys.argv[1], encoding="utf-8").read()
|
||||
source = re.sub(r"//.*", "", source)
|
||||
lines = source.splitlines()
|
||||
signal = sys.argv[2]
|
||||
|
||||
emits = [index for index, line in enumerate(lines)
|
||||
if re.search(rf"\b(root\.)?{re.escape(signal)}\s*\(", line)
|
||||
and not re.match(r"\s*signal\b", line)]
|
||||
if not emits:
|
||||
raise SystemExit(1)
|
||||
|
||||
for emit in emits:
|
||||
# Walk up to the enclosing function, then check whether an eventSounds
|
||||
# early return stands between its first line and the emit.
|
||||
start = 0
|
||||
for index in range(emit, -1, -1):
|
||||
if re.search(r"\bfunction\s+\w+\s*\(", lines[index]):
|
||||
start = index
|
||||
break
|
||||
region = lines[start:emit]
|
||||
for offset, line in enumerate(region):
|
||||
if "eventSounds" not in line:
|
||||
continue
|
||||
# A guard is an eventSounds test followed by a return within a line
|
||||
# or two of it.
|
||||
tail = "\n".join(region[offset:offset + 3])
|
||||
if "return" in tail:
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
|
||||
# ...and the rest of the bell's eligibility DOES apply, otherwise the flash
|
||||
# is not "the bell, seen" but a second, louder notifier that ignores every
|
||||
# per-application rule somebody set.
|
||||
python3 - "$notifs" "$flash_signal" <<'PY' || note "the $flash_signal signal does not sit on the bell's eligibility path, so the flash ignores the per-application sound rule, low urgency, and suppress-sound"
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = re.sub(r"//.*", "", open(sys.argv[1], encoding="utf-8").read())
|
||||
lines = source.splitlines()
|
||||
signal = sys.argv[2]
|
||||
|
||||
emits = [index for index, line in enumerate(lines)
|
||||
if re.search(rf"\b(root\.)?{re.escape(signal)}\s*\(", line)
|
||||
and not re.match(r"\s*signal\b", line)]
|
||||
if not emits:
|
||||
raise SystemExit(1)
|
||||
|
||||
markers = ("appRule", "suppress-sound", "NotificationUrgency.Low", "bell")
|
||||
for emit in emits:
|
||||
start = 0
|
||||
for index in range(emit, -1, -1):
|
||||
if re.search(r"\bfunction\s+(\w+)\s*\(", lines[index]):
|
||||
start = index
|
||||
break
|
||||
region = "\n".join(lines[start:emit + 1])
|
||||
if not any(marker in region for marker in markers):
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
|
||||
# The rule is subtle enough that the next reader will "fix" it unless the
|
||||
# file says why. Pinned so the explanation cannot be deleted separately
|
||||
# from the behaviour.
|
||||
grep -qiE 'eventSounds' "$notifs" \
|
||||
|| note 'Notifs no longer mentions eventSounds at all, so the deliberate exception has nothing to be an exception to'
|
||||
python3 - "$notifs" <<'PY' || note 'nothing in Notifs.qml explains why the visual flash is not gated on event sounds -- an undocumented exception to the bell rule is one somebody will helpfully remove'
|
||||
import re
|
||||
import sys
|
||||
|
||||
comments = "\n".join(re.findall(r"//.*", open(sys.argv[1], encoding="utf-8").read())).lower()
|
||||
hits = ("flash" in comments or "visual" in comments) and (
|
||||
"hear" in comments or "eventsounds" in comments or "event sounds" in comments)
|
||||
raise SystemExit(0 if hits else 1)
|
||||
PY
|
||||
fi
|
||||
|
||||
# Per-screen, like every other overlay in this shell: a flash on one display of
|
||||
# three is a notification most of the screen never showed.
|
||||
grep -q 'VisualBell' "$shell_qml" \
|
||||
|| note 'shell.qml never instantiates VisualBell, so the overlay exists but is never on screen'
|
||||
|
||||
# ── 3. Orca, as a process ────────────────────────────────────────────────────
|
||||
|
||||
service_code="$(sed -E 's://.*::' "$service")"
|
||||
|
||||
# THE assertion, swept over everything this redesign touches rather than over
|
||||
# the service alone: gnome-settings-daemon applies screen-reader-enabled, and
|
||||
# it does not run here.
|
||||
while IFS= read -r hit; do
|
||||
note "screen-reader-enabled is written in ${hit%%:*} -- that key is applied by gnome-settings-daemon, which this session does not run, so the write starts nothing"
|
||||
done < <(grep -rn 'screen-reader-enabled' "$shell_dir" --include='*.qml' --include='*.js' \
|
||||
| sed -E 's://.*::' | grep 'screen-reader-enabled' || true)
|
||||
|
||||
grep -q 'orcaRunning' "$service" \
|
||||
|| note 'services/Accessibility.qml exposes no orcaRunning, so the page cannot say whether the screen reader is on'
|
||||
grep -qE '\bpgrep\b' <<<"$service_code" \
|
||||
|| note 'nothing probes for a running Orca process, so the running state is a guess'
|
||||
grep -qE 'gsettings[^\n]*(screen-reader|a11y|applications)' <<<"$service_code" \
|
||||
&& note 'Orca is being controlled through gsettings rather than as a process'
|
||||
|
||||
# Poll discipline. A pgrep on a repeating timer is a subprocess every few
|
||||
# seconds for the whole session, for a page almost nobody has open.
|
||||
python3 - "$service" <<'PY' || note 'the Orca probe runs on a repeating Timer, so the shell spawns a pgrep forever for a page that is almost never open -- the probe belongs on page open and after each action'
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = re.sub(r"//.*", "", open(sys.argv[1], encoding="utf-8").read())
|
||||
for match in re.finditer(r"Timer \{", source):
|
||||
depth = 0
|
||||
body = []
|
||||
for char in source[match.start():]:
|
||||
body.append(char)
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
block = "".join(body)
|
||||
if "repeat: true" in block and ("orca" in block.lower() or "pgrep" in block):
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
|
||||
# The readiness line the mock shows is a real reading, not a sentence.
|
||||
page_matches -i 'accessibility bus|a11y bus|at-spi' \
|
||||
|| note 'the screen reader card does not mention the accessibility bus, which is the one thing that decides whether starting Orca would achieve anything'
|
||||
|
||||
# ── 4. The page, per the approved mock ───────────────────────────────────────
|
||||
|
||||
# Five cards, and the five subjects people arrive with. The old page sorted by
|
||||
# mechanism (Pointer, Text, Magnifier, Contrast); this one sorts by which sense
|
||||
# or limb is being accommodated, which is how somebody looking for help thinks
|
||||
# about it.
|
||||
for card in Vision Motion Hearing 'Keyboard' 'Screen reader'; do
|
||||
grep -Eqi "title: \"[^\"]*${card}" <<<"$page_code" \
|
||||
|| note "the page has no \"$card\" card; the approved structure is Vision / Motion / Hearing / Keyboard & pointer / Screen reader"
|
||||
done
|
||||
|
||||
for retired in 'title: "Pointer"' 'title: "Text"' 'title: "Magnifier"' \
|
||||
'title: "Contrast"' 'title: "Keyboard accessibility"'; do
|
||||
grep -Fq "$retired" <<<"$page_code" \
|
||||
&& note "the retired card ${retired#title: } is still on the page, so the rebuild left the old mechanism-sorted structure behind it"
|
||||
done
|
||||
|
||||
# Dim amount is meaningless while dim is off, and a live slider that changes
|
||||
# nothing is worse than a greyed one: it invites somebody to conclude the
|
||||
# setting is broken.
|
||||
python3 - "${page_files[@]}" <<'PY' || note 'the dimStrength slider is not gated on dimInactive, so the page offers a live control that does nothing until another switch is on'
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = "\n".join(re.sub(r"//.*", "", open(path, encoding="utf-8").read())
|
||||
for path in sys.argv[1:])
|
||||
for match in re.finditer(r"SliderRow \{", source):
|
||||
depth = 0
|
||||
body = []
|
||||
for char in source[match.start():]:
|
||||
body.append(char)
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
block = "".join(body)
|
||||
if '"dimStrength"' not in block:
|
||||
continue
|
||||
raise SystemExit(0 if re.search(r"enabled:.*dimInactive", block, re.S) else 1)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
# The dead zero label, swept across every settings page rather than only this
|
||||
# one. `SliderRow.display()` substitutes zeroLabel at exactly 0, so a row that
|
||||
# sets one for a setting whose schema minimum is above 0 is copy that can never
|
||||
# appear -- which is what "Off" was doing under a magnifier whose minimum is 1.
|
||||
python3 - "$schema" "$settings" <<'PY' || note 'a SliderRow sets a zeroLabel for a setting whose schema minimum is above zero, so the label can never be shown -- SliderRow.display() substitutes it only at exactly 0'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
schema = open(sys.argv[1], encoding="utf-8").read()
|
||||
minimums = {}
|
||||
for block in re.findall(r'\{\s*\n\s+key: "\w+".*?\n\s{8}\}', schema, re.S):
|
||||
key = re.search(r'key: "(\w+)"', block).group(1)
|
||||
low = re.search(r"\bmin: ([0-9.-]+)", block)
|
||||
if low:
|
||||
minimums[key] = float(low.group(1))
|
||||
|
||||
bad = []
|
||||
for name in sorted(os.listdir(sys.argv[2])):
|
||||
if not name.endswith(".qml"):
|
||||
continue
|
||||
source = re.sub(r"//.*", "", open(os.path.join(sys.argv[2], name), encoding="utf-8").read())
|
||||
for match in re.finditer(r"SliderRow \{[^}]*\}", source):
|
||||
block = match.group(0)
|
||||
if "zeroLabel" not in block:
|
||||
continue
|
||||
key = re.search(r'setting: "(\w+)"', block)
|
||||
if key and minimums.get(key.group(1), 0.0) > 0:
|
||||
bad.append(f"{name}: {key.group(1)}")
|
||||
if bad:
|
||||
print("dead zero labels: " + ", ".join(bad), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
|
||||
# ...and the honesty that replaced it says what "off" is, in the units the
|
||||
# readout uses.
|
||||
python3 - "$schema" "${page_files[@]}" <<'PY' || note 'nothing on the page or in the schema says that a magnification of one is off, so the magnifier has no off position anybody can find'
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = open(sys.argv[1], encoding="utf-8").read()
|
||||
text += "\n".join(open(path, encoding="utf-8").read() for path in sys.argv[2:])
|
||||
raise SystemExit(0 if re.search(r"1(\.0+)?\s*×[^\"]*\bis off\b", text) else 1)
|
||||
PY
|
||||
|
||||
# The chord display. The page tells somebody which keys zoom; if it says so in
|
||||
# a string of its own, that string is a second spelling of keybinds.lua and
|
||||
# will outlive the bind. Either it reads the Keybinds service, or the chords it
|
||||
# names are chords that file really binds.
|
||||
python3 - "$keybinds" "${page_files[@]}" <<'PY' || note 'the page prints zoom chords that keybinds.lua does not bind, so the shortcut it advertises is not the shortcut that works'
|
||||
import re
|
||||
import sys
|
||||
|
||||
binds = open(sys.argv[1], encoding="utf-8").read().lower()
|
||||
source = "\n".join(re.sub(r"//.*", "", open(path, encoding="utf-8").read())
|
||||
for path in sys.argv[2:])
|
||||
|
||||
if "Keybinds." in source:
|
||||
raise SystemExit(0)
|
||||
|
||||
chords = re.findall(r'chord: "([^"]+)"', source)
|
||||
for chord in chords:
|
||||
keys = [part.strip().lower() for part in chord.split("+") if part.strip()]
|
||||
if not keys or "super" not in keys:
|
||||
continue
|
||||
# The chord as keybinds.lua would spell it: "SUPER + equal".
|
||||
if not all(re.search(rf'bind\("[^"]*\b{re.escape(key)}\b', binds) for key in keys):
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
|
||||
# The honesty box. It exists because searching for "sticky keys" and finding
|
||||
# nothing reads as the desktop having no opinion; the box is the opinion, and
|
||||
# it has to carry the evidence rather than a shrug.
|
||||
honesty="$(python3 - "${page_files[@]}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = "\n".join(open(path, encoding="utf-8").read() for path in sys.argv[1:])
|
||||
for match in re.finditer(r"[A-Z][A-Za-z0-9]* \{", source):
|
||||
depth = 0
|
||||
body = []
|
||||
for char in source[match.start():]:
|
||||
body.append(char)
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
block = "".join(body)
|
||||
if re.search(r"sticky", block, re.I) and len(block) < 4000:
|
||||
print(block)
|
||||
break
|
||||
PY
|
||||
)"
|
||||
|
||||
if [[ -z "$honesty" ]]; then
|
||||
note 'the page says nothing about sticky, slow or bounce keys, so someone who needs them is told nothing at all'
|
||||
else
|
||||
grep -qi 'hyprland' <<<"$honesty" \
|
||||
|| note 'the sticky-keys honesty box does not name Hyprland, so it reads as a Wayland-wide excuse rather than as a fact about this compositor'
|
||||
grep -qiE 'asked|probed|checked|no such option|does not implement' <<<"$honesty" \
|
||||
|| note 'the honesty box states the absence without saying it was probed rather than assumed, which is the difference between a finding and a shrug'
|
||||
grep -qiE 'daemon|gnome-settings-daemon|gsd|not run' <<<"$honesty" \
|
||||
|| note 'the honesty box does not explain why the GNOME switches for these are inert here, which is the half people go looking for next'
|
||||
|
||||
# Not styled as a failure. This is a statement of what the compositor does
|
||||
# not implement yet, not a fault the reader has to act on, and dressing it
|
||||
# in the danger token would make an accessibility page open with an alarm.
|
||||
grep -qE 'Theme\.(danger|warn)\b' <<<"$honesty" \
|
||||
&& note 'the honesty box is painted in the danger or warning token, which turns a plain statement of scope into an alarm on the page least able to afford one'
|
||||
for word in Warning Error Unsupported unfortunately sorry Broken; do
|
||||
grep -qw "$word" <<<"$honesty" \
|
||||
&& note "the honesty box uses urgency language (\"$word\"); it is describing what the compositor does not implement yet, not reporting a fault"
|
||||
done
|
||||
fi
|
||||
|
||||
# Mono audio: deferred out loud, with somewhere to go meanwhile.
|
||||
python3 - "${page_files[@]}" <<'PY' || note 'the mono-audio row is missing, or it is missing the NOT YET marking that keeps it from reading as a control'
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = "\n".join(open(path, encoding="utf-8").read() for path in sys.argv[1:])
|
||||
if not re.search(r"mono", source, re.I):
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0 if re.search(r"NOT YET|Not yet|not offered yet", source) else 1)
|
||||
PY
|
||||
page_matches -i 'balance|sound page|Sound settings' \
|
||||
|| note 'the mono-audio row defers without saying where the nearest real thing is; Balance is on the Sound page'
|
||||
|
||||
# The Keyboard jump, through the router rather than by assignment.
|
||||
page_has 'ShellState.openSettings("shortcuts")' \
|
||||
|| note 'the Keyboard & pointer card does not offer the jump to Keyboard settings through openSettings(), which is the only way the routing contract can see where it lands'
|
||||
page_has 'ShellState.settingsPage = ' \
|
||||
&& note 'the page sets the settings page by hand, bypassing SettingsRoutes.resolve()'
|
||||
|
||||
# The page is presentation. Starting Orca is the service's job, so one copy of
|
||||
# it can be shared and the running state can be read back.
|
||||
for file in "${page_files[@]}"; do
|
||||
grep -qE '\bProcess\b' "$file" \
|
||||
&& note "${file#"$settings/"} shells out; the Accessibility page's subprocesses belong in services/Accessibility.qml"
|
||||
done
|
||||
|
||||
# ── 5. The nine primitives ───────────────────────────────────────────────────
|
||||
#
|
||||
# These rows are the settings application. A page is a list of them, so losing
|
||||
# the name or the Tab stop in one file removes a whole class of control from
|
||||
# the keyboard across all fourteen categories at once -- and it does it
|
||||
# silently, since nothing about the row looks or behaves differently.
|
||||
#
|
||||
# Resolved through the QML inheritance chain: ToggleRow extends SettingRow, and
|
||||
# a name inherited from the base is a name the row really has. What may NOT be
|
||||
# inherited is the Tab stop on a control the file declares itself -- SettingRow
|
||||
# is a Tab stop only when it is activatable, which a ToggleRow is not, so a
|
||||
# file that draws its own switch or button has to make that switch reachable.
|
||||
|
||||
python3 - "$settings" <<'PY' || note 'one of the nine shared row primitives is missing a screen-reader name, a role, or a Tab stop (details above)'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
settings = sys.argv[1]
|
||||
primitives = ["SliderRow", "ToggleRow", "SwitchRow", "ActionRow", "ChoiceRow",
|
||||
"SegmentRow", "SettingRow", "OptionPickerRow", "PickerRow"]
|
||||
|
||||
sources = {}
|
||||
bases = {}
|
||||
for name in primitives:
|
||||
path = os.path.join(settings, name + ".qml")
|
||||
if not os.path.exists(path):
|
||||
print(f"{name}.qml does not exist", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
text = re.sub(r"//.*", "", open(path, encoding="utf-8").read())
|
||||
sources[name] = text
|
||||
root = re.search(r"^([A-Z][A-Za-z0-9]*) \{", text, re.M)
|
||||
bases[name] = root.group(1) if root else ""
|
||||
|
||||
|
||||
def chain(name):
|
||||
seen = []
|
||||
while name in sources and name not in seen:
|
||||
seen.append(name)
|
||||
name = bases[name]
|
||||
return seen
|
||||
|
||||
|
||||
# Controls a file declares itself. A row that draws one of these owns an
|
||||
# interactive element, and the Tab stop has to be on it here.
|
||||
CONTROLS = ("MouseArea", "TapHandler", "SettingsToggle", "SettingsButton",
|
||||
"ValueSlider")
|
||||
|
||||
problems = []
|
||||
for name in primitives:
|
||||
inherited = "\n".join(sources[link] for link in chain(name))
|
||||
own = sources[name]
|
||||
|
||||
for needle, what in (("Accessible.name", "a screen-reader name"),
|
||||
("Accessible.role", "a screen-reader role")):
|
||||
if needle not in inherited:
|
||||
problems.append(f"{name}.qml has no {what} ({needle}), so a screen reader reads it as an unnamed element")
|
||||
|
||||
if "activeFocusOnTab" not in inherited:
|
||||
problems.append(f"{name}.qml has no activeFocusOnTab anywhere in its chain, so Tab walks past it")
|
||||
elif any(control in own for control in CONTROLS) and "activeFocusOnTab" not in own:
|
||||
problems.append(f"{name}.qml draws its own control but declares no activeFocusOnTab of its own; the base is a Tab stop only when activatable, which this row is not")
|
||||
|
||||
for problem in problems:
|
||||
print(problem, file=sys.stderr)
|
||||
raise SystemExit(1 if problems else 0)
|
||||
PY
|
||||
|
||||
# Keys reach the controls, not only the rows: Space and Enter activate, and the
|
||||
# two rows that hold a range answer the arrow keys. A focus ring nobody can act
|
||||
# from is a Tab stop that wastes a keystroke.
|
||||
for file in SettingRow ToggleRow SwitchRow ActionRow; do
|
||||
grep -qE 'Keys\.on(Space|Return|Enter)Pressed|Accessible\.onPressAction|Accessible\.onToggleAction' \
|
||||
"$settings/$file.qml" \
|
||||
|| note "$file.qml can be focused but not activated from the keyboard, so Tab lands on a control that does nothing"
|
||||
done
|
||||
for file in SliderRow SegmentRow; do
|
||||
grep -qE 'Keys\.on(Left|Right)Pressed|Accessible\.on(Increase|Decrease)Action' \
|
||||
"$settings/$file.qml" \
|
||||
|| note "$file.qml does not answer the arrow keys, so a focused slider or segment cannot be changed without a pointer"
|
||||
done
|
||||
|
||||
# A focus ring. Focus that cannot be seen is focus a sighted keyboard user
|
||||
# loses track of on the second Tab.
|
||||
python3 - "$settings" <<'PY' || note 'no row primitive draws a visible focus indicator, so keyboard focus is invisible'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
settings = sys.argv[1]
|
||||
names = ["SliderRow", "ToggleRow", "SwitchRow", "ActionRow", "ChoiceRow",
|
||||
"SegmentRow", "SettingRow", "OptionPickerRow", "PickerRow"]
|
||||
for name in names:
|
||||
text = re.sub(r"//.*", "", open(os.path.join(settings, name + ".qml"), encoding="utf-8").read())
|
||||
if re.search(r"activeFocus\b", text) and re.search(r"border\.|Rectangle", text):
|
||||
raise SystemExit(0)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
# And the visual-at-rest promise: these rows are used by every page, so the
|
||||
# accessibility work was allowed to add nothing that shows when nothing is
|
||||
# focused. A border painted unconditionally would restyle all fourteen
|
||||
# categories.
|
||||
python3 - "$settings" <<'PY' || note 'a row primitive paints a focus border unconditionally, which restyles every settings page rather than only the focused row'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
settings = sys.argv[1]
|
||||
names = ["SliderRow", "ToggleRow", "SwitchRow", "ActionRow", "ChoiceRow",
|
||||
"SegmentRow", "SettingRow", "OptionPickerRow", "PickerRow"]
|
||||
for name in names:
|
||||
text = re.sub(r"//.*", "", open(os.path.join(settings, name + ".qml"), encoding="utf-8").read())
|
||||
for match in re.finditer(r"border\.(width|color):\s*([^\n]+)", text):
|
||||
value = match.group(2)
|
||||
if "activeFocus" in value or "focus" in value.lower():
|
||||
continue
|
||||
# A constant border on a focus-named element is the failure; borders
|
||||
# that were always there (the card edge) are not.
|
||||
line_start = text.rfind("\n", 0, match.start())
|
||||
window = text[max(0, line_start - 400):match.start()]
|
||||
if re.search(r"id:\s*focus", window, re.I):
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
|
||||
# ── 6. Reduce motion is true of the shell ────────────────────────────────────
|
||||
#
|
||||
# "Reduce motion" used to still the compositor's windows while the shell's own
|
||||
# bar, dock, panels and OSD went on animating, which made the switch a
|
||||
# half-truth for the surfaces most in front of you. Theme's duration tokens now
|
||||
# collapse to zero, so every Behavior and NumberAnimation in the shell obeys it
|
||||
# without knowing it exists.
|
||||
|
||||
grep -qE 'readonly property bool motionEnabled: *Settings\.animationsEnabled' "$theme" \
|
||||
|| note 'Theme.motionEnabled no longer reads Settings.animationsEnabled, so Reduce motion has stopped reaching the shell'
|
||||
for token in durFast durNormal durSlow durDockReveal; do
|
||||
grep -qE "readonly property int ${token}: *motionEnabled \? [0-9]+ : 0" "$theme" \
|
||||
|| note "Theme.$token does not collapse to 0 when motion is off, so Reduce motion is a half-truth again for whatever uses it"
|
||||
done
|
||||
|
||||
# The switch itself is on the page, mirrored from Appearance.
|
||||
page_has 'setting: "animationsEnabled"' \
|
||||
|| note 'the Motion card does not carry the animations switch'
|
||||
page_has 'setting: "visualAlerts"' \
|
||||
|| note 'the Hearing card does not carry the visual alerts switch'
|
||||
|
||||
# ── 7. Findable ──────────────────────────────────────────────────────────────
|
||||
#
|
||||
# The schema covers the switches by their own labels. These are the words
|
||||
# people arrive with that no label uses.
|
||||
for entry in 'Zoom in and out' 'Reduce motion' 'Visual alerts' 'Screen reader' \
|
||||
'Orca' 'Sticky keys'; do
|
||||
grep -Fq "label: \"$entry\"" "$search" \
|
||||
|| note "\"$entry\" is not in the search index, so typing it finds nothing on a desktop that has an answer for it"
|
||||
done
|
||||
python3 - "$search" <<'PY' || note 'an accessibility search entry routes somewhere other than the accessibility page'
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = open(sys.argv[1], encoding="utf-8").read()
|
||||
wanted = {"Zoom in and out", "Reduce motion", "Visual alerts", "Screen reader",
|
||||
"Orca", "Sticky keys", "Magnifier zoom"}
|
||||
for match in re.finditer(r'\{ label: "([^"]+)", detail: "[^"]*", page: "([a-z-]+)" \}', source):
|
||||
label, page = match.groups()
|
||||
if label in wanted and page != "accessibility":
|
||||
print(f"{label} routes to {page}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
|
||||
# High contrast is deliberately NOT an extra entry: it is a schema label, and
|
||||
# the index already covers every schema label. A second copy would show the
|
||||
# same setting twice in one result list.
|
||||
grep -Fq 'label: "High contrast", detail:' "$search" \
|
||||
&& note 'High contrast was added to the extra entries, but it is already a schema label -- the index would show it twice'
|
||||
|
||||
if (( ${#findings[@]} > 0 )); then
|
||||
printf 'accessibility contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||
printf ' - %s\n' "${findings[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'accessibility contract: PASS\n'
|
||||
@@ -194,7 +194,23 @@ for (const required of ["rule.urgency === \"low\"", "rule.urgency === \"critical
|
||||
fail(`effectiveUrgency is missing ${required}`);
|
||||
}
|
||||
|
||||
const bell = functionBody("playBell");
|
||||
// The DECISION PATH of the bell, rather than the text of one function. Visual
|
||||
// alerts split the shared gates out into `bellWouldRing`, so the screen flash
|
||||
// can follow the same rules as the chime without following the event-sounds
|
||||
// switch (the rule is pinned above that function). The gates below did not
|
||||
// change; they moved one call up, and a contract reading only the body of
|
||||
// playBell would have reported the per-application sound switch as dropped.
|
||||
//
|
||||
// NOTE: this whole block is a bun -e argument in single quotes. No apostrophes.
|
||||
const playBell = functionBody("playBell");
|
||||
const bell = playBell + functionBody("bellWouldRing");
|
||||
|
||||
// ...and the split only holds while playBell actually consults the predicate.
|
||||
// A playBell that stopped calling it would leave every gate below in a
|
||||
// function nothing runs, which reads exactly like a passing contract.
|
||||
if (!/bellWouldRing\(notification\)/.test(playBell))
|
||||
fail("playBell no longer consults bellWouldRing, so the per-application, urgency and suppress-sound gates sit in a function the bell never calls");
|
||||
|
||||
if (!bell.includes("root.effectiveUrgency(notification) === NotificationUrgency.Low"))
|
||||
fail("the bell reads the claimed urgency rather than the effective one, so treat-as-low would still chime");
|
||||
// The per-application sound switch is narrower than the enabled switch: the
|
||||
|
||||
@@ -16,6 +16,17 @@ assert.equal(model.iconFor('volume', 0.9), 'audio-volume-high-symbolic')
|
||||
assert.equal(model.iconFor('microphone-muted', 0.7), 'microphone-sensitivity-muted-symbolic')
|
||||
assert.equal(model.iconFor('brightness', 0.4), 'display-brightness-symbolic')
|
||||
|
||||
// The magnifier's OSD, posted by Accessibility.stepZoom when a zoom keybind is
|
||||
// pressed. An unmapped kind falls through iconFor to the kind's own name, and
|
||||
// "zoom" is not an icon -- the OSD would draw the generic fallback glyph for
|
||||
// the one shortcut whose whole job is telling somebody who cannot read the
|
||||
// screen what the magnification now is.
|
||||
// An empty bar is 1.00 ×, which is off rather than "barely magnified", so it
|
||||
// gets the reset icon: the zoom-in glyph beside an empty bar would read as a
|
||||
// magnifier that had stopped working.
|
||||
assert.equal(model.iconFor('zoom', 0.4), 'zoom-in-symbolic')
|
||||
assert.equal(model.iconFor('zoom', 0), 'zoom-original-symbolic')
|
||||
|
||||
assert.deepEqual(
|
||||
model.progressState('volume', 140, 100, '', 900),
|
||||
{
|
||||
|
||||
@@ -242,9 +242,13 @@ rg -Fq 'label: "Set up dictation"' "$dictation_page" \
|
||||
|| fail 'DictationPage has no setup action, so the one-time install cannot be started from Settings'
|
||||
rg -Fq 'onTriggered: Dictation.setup()' "$dictation_page" \
|
||||
|| fail 'the dictation setup action does not call the helper that installs both the speech server and the model'
|
||||
rg -Fq 'label: "Speech server"' "$dictation_page" \
|
||||
|| fail 'DictationPage does not report whether the speech server is installed'
|
||||
rg -Fq 'label: "Speech model"' "$dictation_page" \
|
||||
# The Input-phase rebuild replaced the two literal TextRows with a status hero
|
||||
# and setup steps, so the pin follows the state properties rather than the old
|
||||
# row labels -- what matters is that the page still tells the truth about both
|
||||
# halves of the install, not what furniture carries it.
|
||||
rg -Fq 'Dictation.serverReady' "$dictation_page" \
|
||||
|| fail 'DictationPage does not report whether the speech server is answering'
|
||||
rg -Fq 'Dictation.modelInstalled' "$dictation_page" \
|
||||
|| fail 'DictationPage does not report whether the speech model is installed'
|
||||
rg -Fq 'Dictation.typingAvailable' "$dictation_page" \
|
||||
|| fail 'DictationPage does not say when wtype is missing, so dictated text would silently go to the clipboard'
|
||||
|
||||
Reference in New Issue
Block a user