Make every settings row reachable, and every accessibility switch honest

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