mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-17 00:34:01 -04:00
outputs: cycle connected displays on niri (#3362)
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
name: QML tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master, main, "stable-*"]
|
||||
paths:
|
||||
- "quickshell/**"
|
||||
- "dank-qml-common"
|
||||
- "Makefile"
|
||||
- "flake.*"
|
||||
- "distro/nix/**"
|
||||
- ".github/workflows/qml-tests.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-qml:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v31
|
||||
|
||||
- name: Run QML tests
|
||||
run: nix develop --command make test-qml
|
||||
@@ -378,6 +378,7 @@ jobs:
|
||||
# Tar the CONTENTS of quickshell/, not the directory itself
|
||||
(cd quickshell && tar --exclude='.git' \
|
||||
--exclude='.github' \
|
||||
--exclude='./tests' \
|
||||
--exclude='*.tar.gz' \
|
||||
-czf ../_release_assets/dms-qml.tar.gz .)
|
||||
|
||||
|
||||
+3
-1
@@ -143,7 +143,9 @@ touch .qmlls.ini
|
||||
|
||||
5. Run `make lint-qml` from the repo root to lint QML entrypoints (requires the `.qmlls.ini` generated above). The script needs the **Qt 6** `qmllint`; it checks `qmllint6`, Fedora's `qmllint-qt6`, `/usr/lib/qt6/bin/qmllint`, then `qmllint` in `PATH`. If your Qt 6 binary lives elsewhere, set `QMLLINT=/path/to/qmllint`.
|
||||
|
||||
6. Make your changes, test, and open a pull request.
|
||||
6. Run `make test-qml` for QML unit tests (`nix develop --command make test-qml` with Nix). These use Qt 6 `qmltestrunner` offscreen and do not require a running shell. Set `QMLTESTRUNNER=/path/to/qmltestrunner` if needed.
|
||||
|
||||
7. Make your changes, test, and open a pull request.
|
||||
|
||||
### I18n/Localization
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ USER_HOME := $(if $(SUDO_USER),$(shell getent passwd $(SUDO_USER) | cut -d: -f6)
|
||||
SYSTEMD_USER_DIR=$(USER_HOME)/.config/systemd/user
|
||||
|
||||
SHELL_DIR=quickshell
|
||||
QMLTESTRUNNER ?= qmltestrunner
|
||||
SHELL_INSTALL_DIR=$(DATA_DIR)/quickshell/dms
|
||||
ASSETS_DIR=assets
|
||||
APPLICATIONS_DIR=$(DATA_DIR)/applications
|
||||
@@ -41,6 +42,10 @@ clean:
|
||||
lint-qml:
|
||||
@./quickshell/scripts/qmllint-entrypoints.sh
|
||||
|
||||
.PHONY: test-qml
|
||||
test-qml:
|
||||
QT_QPA_PLATFORM=offscreen $(QMLTESTRUNNER) -input quickshell/tests -o -,txt
|
||||
|
||||
# Pull the latest dank-qml-common and pin it everywhere it is consumed
|
||||
# (submodule pointer + nix flake input). Commit both in one change.
|
||||
update-common:
|
||||
@@ -147,6 +152,7 @@ help:
|
||||
@echo " build - Same as 'all'"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo " lint-qml - Run qmllint on shell entrypoints using the Quickshell tooling VFS"
|
||||
@echo " test-qml - Run QML unit tests with Qt 6 qmltestrunner"
|
||||
@echo ""
|
||||
@echo "Install:"
|
||||
@echo " install - Build and install everything (requires sudo)"
|
||||
|
||||
@@ -51,6 +51,7 @@ sync-shell:
|
||||
@mkdir -p $(EMBED_DIR)
|
||||
@tar -C $(SHELL_SRC) --exclude=.qmlls.ini -chf - . | tar -C $(EMBED_DIR) -xf -
|
||||
@rm -rf $(EMBED_DIR)/.git* $(EMBED_DIR)/.github
|
||||
@rm -rf $(EMBED_DIR)/tests
|
||||
@find $(EMBED_DIR) -type d \( -name .claude -o -name .vscode \) -prune -exec rm -rf {} +
|
||||
@rm -f $(EMBED_DIR)/AGENTS.md $(EMBED_DIR)/qmlformat-all.sh
|
||||
@rm -f $(EMBED_DIR)/scripts/i18nsync.py $(EMBED_DIR)/scripts/build-vscode-vsix.sh $(EMBED_DIR)/scripts/qmllint-entrypoints.sh
|
||||
|
||||
@@ -76,7 +76,8 @@ override_dh_auto_install:
|
||||
fi
|
||||
|
||||
rm -rf debian/dms/usr/share/quickshell/dms/core \
|
||||
debian/dms/usr/share/quickshell/dms/distro
|
||||
debian/dms/usr/share/quickshell/dms/distro \
|
||||
debian/dms/usr/share/quickshell/dms/tests
|
||||
|
||||
override_dh_auto_clean:
|
||||
rm -f dms
|
||||
|
||||
@@ -71,6 +71,7 @@ install -Dm644 assets/danklogo.svg %{buildroot}%{_datadir}/icons/hicolor/scalabl
|
||||
|
||||
install -dm755 %{buildroot}%{_datadir}/quickshell/dms
|
||||
cp -r quickshell/* %{buildroot}%{_datadir}/quickshell/dms/
|
||||
rm -rf %{buildroot}%{_datadir}/quickshell/dms/tests
|
||||
|
||||
rm -rf %{buildroot}%{_datadir}/quickshell/dms/.git*
|
||||
rm -f %{buildroot}%{_datadir}/quickshell/dms/.gitignore
|
||||
|
||||
@@ -69,6 +69,7 @@ override_dh_auto_install:
|
||||
rm -rf debian/dms/usr/share/quickshell/dms/core
|
||||
rm -rf debian/dms/usr/share/quickshell/dms/distro
|
||||
rm -rf debian/dms/usr/share/quickshell/dms/assets
|
||||
rm -rf debian/dms/usr/share/quickshell/dms/tests
|
||||
|
||||
# Create VERSION file for Quickshell (stable release format)
|
||||
echo "$(BASE_VERSION)" > debian/dms/usr/share/quickshell/dms/VERSION
|
||||
|
||||
+29
@@ -103,6 +103,35 @@ dms ipc call brightness increment 10 ""
|
||||
dms ipc call brightness decrement 5 "intel_backlight"
|
||||
```
|
||||
|
||||
## Target: `outputs`
|
||||
|
||||
### `cycle`
|
||||
|
||||
Cycles through connected outputs on Niri in stable name order, leaving one enabled after a successful switch.
|
||||
If the next output is disabled, DMS enables it and waits for confirmation before disabling the others.
|
||||
If it is already enabled, DMS disables the others immediately.
|
||||
|
||||
The command returns immediately with one of these statuses:
|
||||
|
||||
- `OUTPUT_CYCLE_ACCEPTED` — DMS started switching outputs; this does not confirm completion.
|
||||
- `OUTPUT_CYCLE_BUSY` — DMS is still waiting for the previously selected output to become enabled.
|
||||
- `OUTPUT_CYCLE_NOOP` — fewer than two outputs are connected, or DMS could not start switching outputs.
|
||||
- `OUTPUT_CYCLE_UNSUPPORTED` — the active compositor is not Niri, its IPC socket is unavailable, or DMS output-state notifications are unavailable.
|
||||
|
||||
If the selected output disconnects and no connected output is enabled, DMS enables the previous connected output in the cycle.
|
||||
This fallback also works before the first cycle command and leaves any already-enabled outputs unchanged.
|
||||
DMS keeps this cycle order in memory until it restarts.
|
||||
|
||||
### Niri keybinding example
|
||||
|
||||
Add this binding to your Niri configuration:
|
||||
|
||||
```kdl
|
||||
binds {
|
||||
Super+P { spawn "dms" "ipc" "call" "outputs" "cycle"; }
|
||||
}
|
||||
```
|
||||
|
||||
## Target: `night`
|
||||
|
||||
Night mode (gamma/color temperature) control.
|
||||
|
||||
@@ -129,6 +129,8 @@
|
||||
postInstall = ''
|
||||
mkdir -p $out/share/quickshell/dms
|
||||
cp -r ${rootSrc}/quickshell/. $out/share/quickshell/dms/
|
||||
chmod -R u+w $out/share/quickshell/dms/tests
|
||||
rm -rf $out/share/quickshell/dms/tests
|
||||
|
||||
rm -f $out/share/quickshell/dms/DankCommon
|
||||
cp -r ${dank-qml-common}/DankCommon $out/share/quickshell/dms/DankCommon
|
||||
|
||||
@@ -1948,6 +1948,10 @@ Item {
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
function cycle(): string {
|
||||
return NiriService.cycleSingleOutput();
|
||||
}
|
||||
|
||||
function listProfiles(): string {
|
||||
const profiles = DisplayConfigState.validatedProfiles;
|
||||
const activeId = SessionData.getActiveDisplayProfile(CompositorService.compositor);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import QtQuick
|
||||
import "OutputCycleState.js" as OutputCycleState
|
||||
|
||||
QtObject {
|
||||
id: root
|
||||
|
||||
required property QtObject wlrOutputService
|
||||
required property QtObject socket
|
||||
required property bool isNiri
|
||||
required property string currentOutput
|
||||
|
||||
property var _outputCycleState: OutputCycleState.emptyState()
|
||||
|
||||
Component.onCompleted: resolveOutputCycle()
|
||||
|
||||
readonly property Connections outputConnection: Connections {
|
||||
target: root.wlrOutputService
|
||||
|
||||
function onStateChanged() {
|
||||
root.resolveOutputCycle();
|
||||
}
|
||||
}
|
||||
|
||||
readonly property Connections socketConnection: Connections {
|
||||
target: root.socket
|
||||
|
||||
function onConnectionStateChanged() {
|
||||
if (root.socket.linkUp)
|
||||
root.resolveOutputCycle();
|
||||
}
|
||||
}
|
||||
|
||||
function outputDescriptors() {
|
||||
return wlrOutputService.outputs
|
||||
.filter(output => output.name)
|
||||
.map(output => ({ id: output.name, enabled: output.enabled }));
|
||||
}
|
||||
|
||||
function sendOutputAction(outputName, action) {
|
||||
if (!isNiri || !socket.connected || !socket.linkUp || !wlrOutputService.wlrOutputAvailable || !wlrOutputService.getOutput(outputName))
|
||||
return false;
|
||||
socket.send({
|
||||
"Output": {
|
||||
"output": outputName,
|
||||
"action": action
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendOutputIntent(intent) {
|
||||
return sendOutputAction(intent.id, intent.enabled ? "On" : "Off");
|
||||
}
|
||||
|
||||
function applyOutputCycleTransition(transition) {
|
||||
const enableIntent = transition.intents.find(intent => intent.enabled);
|
||||
if (enableIntent && !sendOutputIntent(enableIntent)) {
|
||||
_outputCycleState = OutputCycleState.clearPending(transition.state);
|
||||
return false;
|
||||
}
|
||||
|
||||
_outputCycleState = transition.state;
|
||||
for (const intent of transition.intents) {
|
||||
if (intent !== enableIntent)
|
||||
sendOutputIntent(intent);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function cycleSingleOutput() {
|
||||
if (!isNiri || !socket.linkUp || !wlrOutputService.wlrOutputAvailable)
|
||||
return "OUTPUT_CYCLE_UNSUPPORTED";
|
||||
if (_outputCycleState.pending)
|
||||
return "OUTPUT_CYCLE_BUSY";
|
||||
|
||||
const transition = OutputCycleState.requestCycle(_outputCycleState, outputDescriptors(), currentOutput);
|
||||
if (transition.status === "busy")
|
||||
return "OUTPUT_CYCLE_BUSY";
|
||||
if (transition.status === "no-op")
|
||||
return "OUTPUT_CYCLE_NOOP";
|
||||
|
||||
const accepted = applyOutputCycleTransition(transition);
|
||||
return accepted ? "OUTPUT_CYCLE_ACCEPTED" : "OUTPUT_CYCLE_NOOP";
|
||||
}
|
||||
|
||||
function resolveOutputCycle() {
|
||||
if (!isNiri || !socket.linkUp || !wlrOutputService.wlrOutputAvailable)
|
||||
return;
|
||||
applyOutputCycleTransition(OutputCycleState.handleOutputsChanged(_outputCycleState, outputDescriptors()));
|
||||
}
|
||||
}
|
||||
@@ -265,6 +265,14 @@ Singleton {
|
||||
connected: CompositorService.isNiri
|
||||
}
|
||||
|
||||
NiriOutputCycle {
|
||||
id: outputCycle
|
||||
wlrOutputService: WlrOutputService
|
||||
socket: requestSocket
|
||||
isNiri: CompositorService.isNiri
|
||||
currentOutput: root.currentOutput
|
||||
}
|
||||
|
||||
function fetchOutputs() {
|
||||
if (!CompositorService.isNiri)
|
||||
return;
|
||||
@@ -287,6 +295,10 @@ Singleton {
|
||||
});
|
||||
}
|
||||
|
||||
function cycleSingleOutput() {
|
||||
return outputCycle.cycleSingleOutput();
|
||||
}
|
||||
|
||||
function updateDisplayScales() {
|
||||
if (!outputs || Object.keys(outputs).length === 0)
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
.pragma library
|
||||
|
||||
function emptyState() {
|
||||
return {
|
||||
ring: [],
|
||||
selected: "",
|
||||
pending: ""
|
||||
};
|
||||
}
|
||||
|
||||
function requestCycle(state, outputs, currentOutput) {
|
||||
if (state.pending)
|
||||
return result(state, "busy", []);
|
||||
|
||||
const ring = outputNames(outputs);
|
||||
if (ring.length < 2)
|
||||
return result(state, "no-op", []);
|
||||
|
||||
const current = findOutput(outputs, currentOutput) ? currentOutput : (firstEnabled(outputs) || ring[0]);
|
||||
const target = ring[(ring.indexOf(current) + 1) % ring.length];
|
||||
const targetOutput = findOutput(outputs, target);
|
||||
if (targetOutput.enabled)
|
||||
return result(withState(state, { selected: target }), "accepted", disableOthers(outputs, target));
|
||||
|
||||
return result(withState(state, { pending: target }), "accepted", [{ id: target, enabled: true }]);
|
||||
}
|
||||
|
||||
function handleOutputsChanged(state, outputs) {
|
||||
const ring = outputNames(outputs);
|
||||
if (ring.length === 0)
|
||||
return result(state, "", []);
|
||||
|
||||
let nextState = state;
|
||||
let intents = [];
|
||||
if (state.pending) {
|
||||
const pendingOutput = findOutput(outputs, state.pending);
|
||||
if (!pendingOutput) {
|
||||
nextState = withState(nextState, { pending: "" });
|
||||
} else if (pendingOutput.enabled) {
|
||||
nextState = withState(nextState, {
|
||||
pending: "",
|
||||
selected: pendingOutput.id
|
||||
});
|
||||
intents = disableOthers(outputs, pendingOutput.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextState.pending && nextState.selected && !findOutput(outputs, nextState.selected) && !firstEnabled(outputs)) {
|
||||
const fallback = previousConnectedOutput(state.ring, nextState.selected, outputs);
|
||||
if (fallback) {
|
||||
nextState = withState(nextState, { selected: fallback.id });
|
||||
intents = intents.concat([{ id: fallback.id, enabled: true }]);
|
||||
}
|
||||
}
|
||||
|
||||
nextState = withState(nextState, { ring: ring });
|
||||
const enabled = firstEnabled(outputs);
|
||||
if (enabled && enabledOutputCount(outputs) === 1)
|
||||
nextState = withState(nextState, { selected: enabled });
|
||||
return result(nextState, "", intents);
|
||||
}
|
||||
|
||||
function clearPending(state) {
|
||||
return withState(state, { pending: "" });
|
||||
}
|
||||
|
||||
function outputNames(outputs) {
|
||||
return outputs.map(output => output.id).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function findOutput(outputs, id) {
|
||||
return outputs.find(output => output.id === id);
|
||||
}
|
||||
|
||||
function firstEnabled(outputs) {
|
||||
return outputNames(outputs).find(id => findOutput(outputs, id).enabled) || "";
|
||||
}
|
||||
|
||||
function enabledOutputCount(outputs) {
|
||||
return outputs.filter(output => output.enabled).length;
|
||||
}
|
||||
|
||||
function disableOthers(outputs, selected) {
|
||||
return outputs
|
||||
.filter(output => output.enabled && output.id !== selected)
|
||||
.map(output => ({ id: output.id, enabled: false }));
|
||||
}
|
||||
|
||||
function previousConnectedOutput(ring, selected, outputs) {
|
||||
const selectedIndex = ring.indexOf(selected);
|
||||
if (selectedIndex < 0)
|
||||
return null;
|
||||
for (let offset = 1; offset < ring.length; offset++) {
|
||||
const candidate = findOutput(outputs, ring[(selectedIndex - offset + ring.length) % ring.length]);
|
||||
if (candidate)
|
||||
return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function withState(state, changes) {
|
||||
return {
|
||||
ring: changes.ring !== undefined ? changes.ring : state.ring,
|
||||
selected: changes.selected !== undefined ? changes.selected : state.selected,
|
||||
pending: changes.pending !== undefined ? changes.pending : state.pending
|
||||
};
|
||||
}
|
||||
|
||||
function result(state, status, intents) {
|
||||
return {
|
||||
state: state,
|
||||
status: status,
|
||||
intents: intents
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import QtQuick
|
||||
import QtTest
|
||||
import "../Services"
|
||||
|
||||
TestCase {
|
||||
id: testCase
|
||||
name: "NiriOutputCycle"
|
||||
|
||||
property var wlr
|
||||
property var socket
|
||||
property var adapter
|
||||
|
||||
Component {
|
||||
id: wlrFactory
|
||||
QtObject {
|
||||
property bool wlrOutputAvailable: true
|
||||
property var outputs: []
|
||||
signal stateChanged
|
||||
|
||||
function getOutput(name) {
|
||||
return outputs.find(output => output.name === name) || null;
|
||||
}
|
||||
|
||||
function publish(heads) {
|
||||
outputs = heads;
|
||||
stateChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: socketFactory
|
||||
QtObject {
|
||||
property bool connected: true
|
||||
property bool linkUp: true
|
||||
property var requests: []
|
||||
property var afterSend: null
|
||||
signal connectionStateChanged
|
||||
|
||||
function send(request) {
|
||||
requests = requests.concat([request]);
|
||||
if (afterSend)
|
||||
afterSend(request);
|
||||
}
|
||||
|
||||
function reconnect() {
|
||||
linkUp = true;
|
||||
connectionStateChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: adapterFactory
|
||||
NiriOutputCycle {}
|
||||
}
|
||||
|
||||
function head(name, enabled) {
|
||||
return { name: name, enabled: enabled };
|
||||
}
|
||||
|
||||
function start(heads, currentOutput, linkUp = true) {
|
||||
wlr = createTemporaryObject(wlrFactory, testCase, { outputs: heads });
|
||||
socket = createTemporaryObject(socketFactory, testCase, { linkUp: linkUp });
|
||||
adapter = createTemporaryObject(adapterFactory, testCase, {
|
||||
wlrOutputService: wlr,
|
||||
socket: socket,
|
||||
isNiri: true,
|
||||
currentOutput: currentOutput
|
||||
});
|
||||
verify(adapter !== null);
|
||||
}
|
||||
|
||||
function compareRequests(expected) {
|
||||
compare(socket.requests, expected.map(item => ({ Output: { output: item[0], action: item[1] } })));
|
||||
}
|
||||
|
||||
function test_wlrSignalConfirmsDisabledTargetBeforePeersTurnOff() {
|
||||
start([head("eDP-1", true), head("", false), head("DP-1", false)], "eDP-1");
|
||||
|
||||
compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_ACCEPTED");
|
||||
compareRequests([["DP-1", "On"]]);
|
||||
compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_BUSY");
|
||||
|
||||
wlr.publish([head("eDP-1", true), head("DP-1", false)]);
|
||||
compareRequests([["DP-1", "On"]]);
|
||||
compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_BUSY");
|
||||
|
||||
wlr.publish([head("eDP-1", true), head("DP-1", true)]);
|
||||
compareRequests([["DP-1", "On"], ["eDP-1", "Off"]]);
|
||||
}
|
||||
|
||||
function test_connectionDesiredWithoutActualLinkRejectsCycle() {
|
||||
start([head("DP-1", false), head("eDP-1", true)], "eDP-1", false);
|
||||
|
||||
verify(socket.connected);
|
||||
compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_UNSUPPORTED");
|
||||
compareRequests([]);
|
||||
|
||||
socket.reconnect();
|
||||
compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_ACCEPTED");
|
||||
compareRequests([["DP-1", "On"]]);
|
||||
}
|
||||
|
||||
function test_reconnectConsumesCurrentWlrStateWithoutAnotherOutputEvent() {
|
||||
start([head("DP-1", false), head("eDP-1", true)], "eDP-1");
|
||||
compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_ACCEPTED");
|
||||
|
||||
socket.linkUp = false;
|
||||
wlr.publish([head("DP-1", true), head("eDP-1", true)]);
|
||||
compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_UNSUPPORTED");
|
||||
compareRequests([["DP-1", "On"]]);
|
||||
|
||||
socket.reconnect();
|
||||
compareRequests([["DP-1", "On"], ["eDP-1", "Off"]]);
|
||||
}
|
||||
|
||||
function test_unavailableWlrPreservesPendingCycleUntilStateRecovery() {
|
||||
start([head("DP-1", false), head("eDP-1", true)], "eDP-1");
|
||||
compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_ACCEPTED");
|
||||
compareRequests([["DP-1", "On"]]);
|
||||
|
||||
wlr.wlrOutputAvailable = false;
|
||||
wlr.publish([head("DP-1", true), head("eDP-1", true)]);
|
||||
compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_UNSUPPORTED");
|
||||
compareRequests([["DP-1", "On"]]);
|
||||
|
||||
wlr.wlrOutputAvailable = true;
|
||||
compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_BUSY");
|
||||
compareRequests([["DP-1", "On"]]);
|
||||
|
||||
wlr.publish([head("DP-1", true), head("eDP-1", true)]);
|
||||
compareRequests([["DP-1", "On"], ["eDP-1", "Off"]]);
|
||||
}
|
||||
|
||||
function test_initialSelectionRecoversDisabledHeadAfterUnplug() {
|
||||
start([head("DP-1", true), head("eDP-1", false)], "DP-1");
|
||||
compareRequests([]);
|
||||
|
||||
wlr.publish([head("eDP-1", false)]);
|
||||
compareRequests([["eDP-1", "On"]]);
|
||||
|
||||
wlr.publish([head("eDP-1", true)]);
|
||||
compareRequests([["eDP-1", "On"]]);
|
||||
compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_NOOP");
|
||||
}
|
||||
|
||||
function test_unpluggedPendingTargetCancelsAndNewHeadJoinsCycle() {
|
||||
start([head("DP-1", false), head("eDP-1", true)], "eDP-1");
|
||||
compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_ACCEPTED");
|
||||
|
||||
wlr.publish([head("eDP-1", true)]);
|
||||
compareRequests([["DP-1", "On"]]);
|
||||
compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_NOOP");
|
||||
|
||||
wlr.publish([head("HDMI-A-1", false), head("eDP-1", true)]);
|
||||
compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_ACCEPTED");
|
||||
compareRequests([["DP-1", "On"], ["HDMI-A-1", "On"]]);
|
||||
}
|
||||
|
||||
function test_outputMembershipIsCheckedForEachSend() {
|
||||
start([head("DP-1", false), head("HDMI-A-1", true), head("eDP-1", true)], "eDP-1");
|
||||
compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_ACCEPTED");
|
||||
socket.afterSend = function(request) {
|
||||
if (request.Output.action === "Off")
|
||||
wlr.outputs = [head("DP-1", true)];
|
||||
};
|
||||
|
||||
wlr.publish([head("DP-1", true), head("HDMI-A-1", true), head("eDP-1", true)]);
|
||||
compareRequests([["DP-1", "On"], ["HDMI-A-1", "Off"]]);
|
||||
}
|
||||
|
||||
function test_actualLinkIsCheckedForEachSend() {
|
||||
start([head("DP-1", false), head("HDMI-A-1", true), head("eDP-1", true)], "eDP-1");
|
||||
compare(adapter.cycleSingleOutput(), "OUTPUT_CYCLE_ACCEPTED");
|
||||
socket.afterSend = function(request) {
|
||||
if (request.Output.action === "Off")
|
||||
socket.linkUp = false;
|
||||
};
|
||||
|
||||
wlr.publish([head("DP-1", true), head("HDMI-A-1", true), head("eDP-1", true)]);
|
||||
compareRequests([["DP-1", "On"], ["HDMI-A-1", "Off"]]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import QtQuick
|
||||
import QtTest
|
||||
import "../Services/OutputCycleState.js" as OutputCycleState
|
||||
|
||||
TestCase {
|
||||
name: "OutputCycleState"
|
||||
|
||||
function output(id, enabled) {
|
||||
return { id: id, enabled: enabled };
|
||||
}
|
||||
|
||||
function test_disabledTargetStartsPendingCycle() {
|
||||
const result = OutputCycleState.requestCycle(OutputCycleState.emptyState(), [
|
||||
output("HDMI-A-1", false),
|
||||
output("eDP-1", true)
|
||||
], "eDP-1");
|
||||
|
||||
compare(result.status, "accepted");
|
||||
compare(result.state.pending, "HDMI-A-1");
|
||||
compare(result.intents.length, 1);
|
||||
compare(result.intents[0].id, "HDMI-A-1");
|
||||
verify(result.intents[0].enabled);
|
||||
}
|
||||
|
||||
function test_confirmedTargetDisablesEveryOtherEnabledOutput() {
|
||||
const state = { ring: ["HDMI-A-1", "eDP-1", "DP-1"], selected: "eDP-1", pending: "HDMI-A-1" };
|
||||
const result = OutputCycleState.handleOutputsChanged(state, [
|
||||
output("DP-1", true),
|
||||
output("HDMI-A-1", true),
|
||||
output("eDP-1", true)
|
||||
]);
|
||||
|
||||
compare(result.state.pending, "");
|
||||
compare(result.state.selected, "HDMI-A-1");
|
||||
compare(result.intents.length, 2);
|
||||
compare(result.intents[0].id, "DP-1");
|
||||
verify(!result.intents[0].enabled);
|
||||
compare(result.intents[1].id, "eDP-1");
|
||||
verify(!result.intents[1].enabled);
|
||||
}
|
||||
|
||||
function test_alreadyEnabledTargetDisablesPeersImmediately() {
|
||||
const result = OutputCycleState.requestCycle(OutputCycleState.emptyState(), [
|
||||
output("DP-1", true),
|
||||
output("eDP-1", true)
|
||||
], "DP-1");
|
||||
|
||||
compare(result.status, "accepted");
|
||||
compare(result.state.selected, "eDP-1");
|
||||
compare(result.intents.length, 1);
|
||||
compare(result.intents[0].id, "DP-1");
|
||||
verify(!result.intents[0].enabled);
|
||||
}
|
||||
|
||||
function test_emptyMapRetainsState() {
|
||||
const state = { ring: ["HDMI-A-1", "eDP-1"], selected: "HDMI-A-1", pending: "" };
|
||||
const result = OutputCycleState.handleOutputsChanged(state, []);
|
||||
|
||||
compare(result.state.ring, state.ring);
|
||||
compare(result.state.selected, state.selected);
|
||||
compare(result.state.pending, state.pending);
|
||||
compare(result.intents.length, 0);
|
||||
}
|
||||
|
||||
function test_emptyMapRetainsPendingCycleUntilTargetConfirms() {
|
||||
const state = { ring: ["HDMI-A-1", "eDP-1"], selected: "eDP-1", pending: "HDMI-A-1" };
|
||||
const empty = OutputCycleState.handleOutputsChanged(state, []);
|
||||
|
||||
compare(empty.state.ring, state.ring);
|
||||
compare(empty.state.selected, "eDP-1");
|
||||
compare(empty.state.pending, "HDMI-A-1");
|
||||
compare(empty.intents.length, 0);
|
||||
|
||||
const confirmed = OutputCycleState.handleOutputsChanged(empty.state, [
|
||||
output("HDMI-A-1", true),
|
||||
output("eDP-1", true)
|
||||
]);
|
||||
|
||||
compare(confirmed.state.pending, "");
|
||||
compare(confirmed.state.selected, "HDMI-A-1");
|
||||
compare(confirmed.intents.length, 1);
|
||||
compare(confirmed.intents[0].id, "eDP-1");
|
||||
verify(!confirmed.intents[0].enabled);
|
||||
}
|
||||
|
||||
function test_unplugPreservesAllEnabledOutputs() {
|
||||
const state = { ring: ["DP-1", "HDMI-A-1", "eDP-1"], selected: "HDMI-A-1", pending: "" };
|
||||
const result = OutputCycleState.handleOutputsChanged(state, [
|
||||
output("DP-1", true),
|
||||
output("eDP-1", true)
|
||||
]);
|
||||
|
||||
compare(result.state.pending, "");
|
||||
compare(result.intents, []);
|
||||
}
|
||||
|
||||
function test_disabledFallbackOnlyEnablesPreviousOutput() {
|
||||
const state = { ring: ["DP-1", "HDMI-A-1", "eDP-1"], selected: "HDMI-A-1", pending: "" };
|
||||
const result = OutputCycleState.handleOutputsChanged(state, [
|
||||
output("DP-1", false),
|
||||
output("eDP-1", false)
|
||||
]);
|
||||
|
||||
compare(result.state.pending, "");
|
||||
compare(result.state.selected, "DP-1");
|
||||
compare(result.intents.length, 1);
|
||||
compare(result.intents[0].id, "DP-1");
|
||||
verify(result.intents[0].enabled);
|
||||
}
|
||||
|
||||
function test_unplugWithoutCyclePreservesWorkingDisplay() {
|
||||
let state = OutputCycleState.handleOutputsChanged(OutputCycleState.emptyState(), [
|
||||
output("DP-1", true), output("DP-2", false), output("eDP-1", false)
|
||||
]).state;
|
||||
state = OutputCycleState.handleOutputsChanged(state, [
|
||||
output("DP-1", true), output("DP-2", true), output("eDP-1", false)
|
||||
]).state;
|
||||
const unplugged = OutputCycleState.handleOutputsChanged(state, [
|
||||
output("DP-2", true), output("eDP-1", false)
|
||||
]);
|
||||
compare(unplugged.intents, []);
|
||||
compare(unplugged.state.pending, "");
|
||||
const repeated = OutputCycleState.handleOutputsChanged(unplugged.state, [
|
||||
output("DP-2", true), output("eDP-1", false)
|
||||
]);
|
||||
compare(repeated.intents, []);
|
||||
}
|
||||
|
||||
function test_fallbackConfirmationDoesNotDisableNewlyEnabledPeer() {
|
||||
const state = OutputCycleState.handleOutputsChanged(OutputCycleState.emptyState(), [
|
||||
output("DP-1", true), output("DP-2", false), output("eDP-1", false)
|
||||
]).state;
|
||||
const unplugged = OutputCycleState.handleOutputsChanged(state, [
|
||||
output("DP-2", false), output("eDP-1", false)
|
||||
]);
|
||||
compare(unplugged.intents, [{ id: "eDP-1", enabled: true }]);
|
||||
const confirmed = OutputCycleState.handleOutputsChanged(unplugged.state, [
|
||||
output("DP-2", true), output("eDP-1", true)
|
||||
]);
|
||||
compare(confirmed.intents, []);
|
||||
compare(confirmed.state.pending, "");
|
||||
}
|
||||
|
||||
function test_missingPendingTargetCancelsWithoutIntent() {
|
||||
const state = { ring: ["HDMI-A-1", "eDP-1"], selected: "eDP-1", pending: "HDMI-A-1" };
|
||||
const result = OutputCycleState.handleOutputsChanged(state, [output("eDP-1", true)]);
|
||||
|
||||
compare(result.state.pending, "");
|
||||
compare(result.intents.length, 0);
|
||||
}
|
||||
|
||||
function test_pendingCycleIsBusy() {
|
||||
const state = { ring: ["HDMI-A-1", "eDP-1"], selected: "eDP-1", pending: "HDMI-A-1" };
|
||||
const result = OutputCycleState.requestCycle(state, [output("HDMI-A-1", false), output("eDP-1", true)], "eDP-1");
|
||||
|
||||
compare(result.status, "busy");
|
||||
compare(result.intents.length, 0);
|
||||
}
|
||||
|
||||
function test_singleOutputIsNoop() {
|
||||
const state = OutputCycleState.emptyState();
|
||||
const result = OutputCycleState.requestCycle(state, [output("eDP-1", true)], "eDP-1");
|
||||
|
||||
compare(result.status, "no-op");
|
||||
compare(result.intents.length, 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user