outputs: cycle connected displays on niri (#3362)

This commit is contained in:
Vladimir
2026-09-09 10:20:38 -04:00
committed by GitHub
parent 8fe918ff24
commit 4b04af121f
16 changed files with 650 additions and 2 deletions
+31
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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)"
+1
View File
@@ -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
+2 -1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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.
+2
View File
@@ -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
+4
View File
@@ -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);
+91
View File
@@ -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()));
}
}
+12
View File
@@ -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;
+115
View File
@@ -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
};
}
+184
View File
@@ -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"]]);
}
}
+167
View File
@@ -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);
}
}