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
@@ -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