Make every settings row reachable, and every accessibility switch honest
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user