diff --git a/config/dot/quickshell/home-brightness-slider-harness.qml b/config/dot/quickshell/home-brightness-slider-harness.qml new file mode 100644 index 0000000..cc1fb4f --- /dev/null +++ b/config/dot/quickshell/home-brightness-slider-harness.qml @@ -0,0 +1,54 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.modules.quicksettings + +ShellRoot { + id: root + + property int confirmedValue: 30 + property int commitCount: 0 + property int lastCommit: -1 + + HomeBrightnessSlider { + id: slider + width: 200 + value: root.confirmedValue + accessibleName: "Desk lamp brightness" + onCommitted: value => { + root.commitCount += 1; + root.lastCommit = value; + } + } + + IpcHandler { + target: "home-brightness-slider-test" + + function reset(value: int): void { + root.confirmedValue = value; + root.commitCount = 0; + root.lastCommit = -1; + slider.cancelPointerInteraction(); + } + function external(value: int): void { root.confirmedValue = value; } + function press(position: int): void { slider.beginPointerInteraction(position); } + function move(position: int): void { slider.movePointerInteraction(position); } + function release(): void { slider.releasePointerInteraction(); } + function cancel(): void { slider.cancelPointerInteraction(); } + function wheel(delta: int): void { slider.commitWheel(delta); } + function status(): string { + return JSON.stringify({ + confirmedValue: root.confirmedValue, + previewValue: slider.previewValue, + interactionActive: slider.interactionActive, + commitCount: root.commitCount, + lastCommit: root.lastCommit, + accessibleRoleIsSlider: slider.Accessible.role === Accessible.Slider, + accessibleName: slider.Accessible.name, + accessibleDescription: slider.Accessible.description, + accessibleFocusable: slider.Accessible.focusable + }); + } + } +} diff --git a/config/dot/quickshell/modules/quicksettings/HomeBrightnessSlider.qml b/config/dot/quickshell/modules/quicksettings/HomeBrightnessSlider.qml index a2ffb4f..0cd2218 100644 --- a/config/dot/quickshell/modules/quicksettings/HomeBrightnessSlider.qml +++ b/config/dot/quickshell/modules/quicksettings/HomeBrightnessSlider.qml @@ -9,8 +9,10 @@ Item { property int value: 0 property int previewValue: 0 + property bool interactionActive: false + property string accessibleName: "Brightness" - readonly property bool pressed: drag.pressed + readonly property bool pressed: root.interactionActive signal previewChanged(int value) signal committed(int value) @@ -20,8 +22,18 @@ Item { activeFocusOnTab: root.enabled opacity: root.enabled ? 1 : 0.42 + Accessible.role: Accessible.Slider + Accessible.name: root.accessibleName + // Qt 6.11's installed Accessible attached type has no structured value or + // range properties, so expose both in the supported live description. + Accessible.description: root.previewValue + " percent, range 0 to 100" + Accessible.focusable: root.enabled + Accessible.focused: root.activeFocus + Accessible.onIncreaseAction: root.commitStep(5) + Accessible.onDecreaseAction: root.commitStep(-5) + onValueChanged: { - if (!drag.pressed) + if (!root.interactionActive) root.updatePreview(root.value, false); } @@ -53,7 +65,7 @@ Item { color: Theme.warn Behavior on width { - enabled: !drag.pressed + enabled: !root.interactionActive NumberAnimation { duration: Theme.durFast easing.type: Easing.OutQuad @@ -82,26 +94,21 @@ Item { anchors.fill: parent enabled: root.enabled hoverEnabled: true - preventStealing: true cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor onPressed: event => { root.forceActiveFocus(); - root.previewAt(event.x); + root.beginPointerInteraction(event.x); event.accepted = true; } - onPositionChanged: event => { - if (pressed) - root.previewAt(event.x); - } + onPositionChanged: event => root.movePointerInteraction(event.x) onReleased: event => { - root.committed(root.previewValue); + root.releasePointerInteraction(); event.accepted = true; } - onCanceled: root.updatePreview(root.value, false) + onCanceled: root.cancelPointerInteraction() onWheel: event => { - const direction = event.angleDelta.y >= 0 ? 5 : -5; - root.commitStep(direction); + root.commitWheel(event.angleDelta.y); event.accepted = true; } } @@ -123,6 +130,36 @@ Item { root.updatePreview(pointerX / Math.max(1, drag.width) * 100, true); } + function beginPointerInteraction(pointerX: real): void { + if (!root.enabled) + return; + root.interactionActive = true; + root.previewAt(pointerX); + } + + function movePointerInteraction(pointerX: real): void { + if (root.interactionActive) + root.previewAt(pointerX); + } + + function releasePointerInteraction(): void { + if (!root.interactionActive) + return; + root.interactionActive = false; + root.committed(root.previewValue); + } + + function cancelPointerInteraction(): void { + root.interactionActive = false; + root.updatePreview(root.value, false); + } + + function commitWheel(delta: int): void { + if (delta === 0) + return; + root.commitStep(delta > 0 ? 5 : -5); + } + function commitStep(delta: int): void { if (!root.enabled) return; diff --git a/config/dot/quickshell/modules/quicksettings/HomeTile.qml b/config/dot/quickshell/modules/quicksettings/HomeTile.qml index 9bec1df..b25e1c2 100644 --- a/config/dot/quickshell/modules/quicksettings/HomeTile.qml +++ b/config/dot/quickshell/modules/quicksettings/HomeTile.qml @@ -140,6 +140,7 @@ Rectangle { ? root.pendingBrightness : root.confirmedBrightness enabled: root.dimmerEnabled + accessibleName: root.entity.name + " brightness" onCommitted: value => root.brightnessRequested(value) } diff --git a/tests/quickshell/control-center-contract.sh b/tests/quickshell/control-center-contract.sh index 3c824bb..5d54027 100755 --- a/tests/quickshell/control-center-contract.sh +++ b/tests/quickshell/control-center-contract.sh @@ -113,12 +113,21 @@ rg -Fq 'signal previewChanged(int value)' "$quicksettings_path/HomeBrightnessSli || fail 'brightness slider has no preview contract' rg -Fq 'signal committed(int value)' "$quicksettings_path/HomeBrightnessSlider.qml" \ || fail 'brightness slider has no release-commit contract' -rg -Fq 'onReleased:' "$quicksettings_path/HomeBrightnessSlider.qml" \ - || fail 'brightness slider does not commit on pointer release' +rg -Fq 'onReleased: event =>' "$quicksettings_path/HomeBrightnessSlider.qml" \ + || fail 'brightness slider does not route pointer release through its interaction state' +rg -Fq 'root.releasePointerInteraction();' "$quicksettings_path/HomeBrightnessSlider.qml" \ + || fail 'brightness slider release is not wired to one-shot commit semantics' +rg -Fq 'onCanceled: root.cancelPointerInteraction()' "$quicksettings_path/HomeBrightnessSlider.qml" \ + || fail 'brightness slider does not restore its external value when a Flickable steals the pointer' +if rg -Fq 'preventStealing: true' "$quicksettings_path/HomeBrightnessSlider.qml"; then + fail 'brightness slider blocks the expanded shelf from stealing vertical drags' +fi rg -Fq 'onWheel:' "$quicksettings_path/HomeBrightnessSlider.qml" \ || fail 'brightness slider has no wheel commit path' rg -Fq 'onCommitted: value => root.brightnessRequested(value)' "$quicksettings_path/HomeTile.qml" \ || fail 'Home tile does not forward the slider release commit' +rg -Fq 'accessibleName: root.entity.name + " brightness"' "$quicksettings_path/HomeTile.qml" \ + || fail 'Home tile does not give its dimmer an accessory-specific accessible name' cp -a "$source_config_path" "$config_path" : >"$helper_log" diff --git a/tests/quickshell/home-brightness-slider-contract.sh b/tests/quickshell/home-brightness-slider-contract.sh new file mode 100755 index 0000000..6a64e51 --- /dev/null +++ b/tests/quickshell/home-brightness-slider-contract.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +harness="$repo_dir/config/dot/quickshell/home-brightness-slider-harness.qml" +state_home="$(mktemp -d /tmp/panama-home-brightness-slider.XXXXXX)" +shell_log="$state_home/quickshell.log" + +fail() { + printf 'Home brightness slider contract: %s\n' "$1" >&2 + exit 1 +} + +qs_for_harness() { + QS_DISABLE_CRASH_HANDLER=1 XDG_STATE_HOME="$state_home" \ + qs -p "$harness" "$@" +} + +cleanup() { + qs_for_harness kill >/dev/null 2>&1 || true + rm -rf "$state_home" +} +trap cleanup EXIT + +start_harness() { + qs_for_harness --daemonize >"$shell_log" 2>&1 + for _ in $(seq 1 50); do + if qs_for_harness ipc show 2>/dev/null \ + | rg -q '^target home-brightness-slider-test$'; then + return + fi + sleep 0.1 + done + sed -n '1,160p' "$shell_log" >&2 + fail 'isolated slider harness did not start' +} + +assert_status() { + local filter="$1" + local message="$2" + local status="" + + status="$(qs_for_harness ipc call home-brightness-slider-test status)" + jq -e "$filter" <<<"$status" >/dev/null || { + printf 'Slider status: %s\n' "$status" >&2 + fail "$message" + } +} + +start_harness + +qs_for_harness ipc call home-brightness-slider-test reset 30 >/dev/null +assert_status \ + '.accessibleRoleIsSlider == true and .accessibleName == "Desk lamp brightness" and .accessibleDescription == "30 percent, range 0 to 100" and .accessibleFocusable == true' \ + 'slider accessibility metadata is incomplete or incorrect' +qs_for_harness ipc call home-brightness-slider-test press 140 >/dev/null +assert_status \ + '.confirmedValue == 30 and .previewValue == 70 and .interactionActive == true and .commitCount == 0 and .accessibleDescription == "70 percent, range 0 to 100"' \ + 'pointer press did not remain a local preview' +qs_for_harness ipc call home-brightness-slider-test release >/dev/null +assert_status \ + '.previewValue == 70 and .interactionActive == false and .commitCount == 1 and .lastCommit == 70' \ + 'one pointer release did not emit exactly one commit' +qs_for_harness ipc call home-brightness-slider-test release >/dev/null +assert_status '.commitCount == 1' 'release without an interaction emitted another commit' + +qs_for_harness ipc call home-brightness-slider-test reset 30 >/dev/null +qs_for_harness ipc call home-brightness-slider-test wheel 120 >/dev/null +assert_status \ + '.previewValue == 35 and .interactionActive == false and .commitCount == 1 and .lastCommit == 35' \ + 'one wheel event did not emit exactly one five-point commit' + +qs_for_harness ipc call home-brightness-slider-test reset 30 >/dev/null +qs_for_harness ipc call home-brightness-slider-test press 160 >/dev/null +assert_status \ + '.previewValue == 80 and .interactionActive == true and .commitCount == 0' \ + 'cancellation setup did not enter local preview state' +qs_for_harness ipc call home-brightness-slider-test external 45 >/dev/null +assert_status \ + '.confirmedValue == 45 and .previewValue == 80 and .interactionActive == true and .commitCount == 0' \ + 'external confirmed or pending state replaced an active local preview' +qs_for_harness ipc call home-brightness-slider-test cancel >/dev/null +assert_status \ + '.confirmedValue == 45 and .previewValue == 45 and .interactionActive == false and .commitCount == 0 and .lastCommit == -1' \ + 'canceled interaction committed or failed to restore the external value' + +trap - EXIT +cleanup +printf 'Home brightness slider contract: PASS\n'