diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index cb189a31..93572a01 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -351,7 +351,7 @@ Item { MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton - onClicked: function(mouse) { + onDoubleClicked: function(mouse) { if (mouse.button === Qt.RightButton) root.openThemeSwitcher() else root.openSelector() mouse.accepted = true diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index f011e242..d75f022d 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -61,6 +61,8 @@ Item { property int tooltipRequest: 0 property var activePopout: null property var clickTargets: [] + property var debugModuleSlots: [] + function registerClickTarget(target) { if (!target || clickTargets.indexOf(target) !== -1) return var next = clickTargets.slice() @@ -73,6 +75,44 @@ Item { clickTargets = next } + function registerDebugModuleSlot(slot) { + if (!slot || debugModuleSlots.indexOf(slot) !== -1) return + var next = debugModuleSlots.slice() + next.push(slot) + debugModuleSlots = next + } + + function unregisterDebugModuleSlot(slot) { + var next = debugModuleSlots.filter(function(item) { return item !== slot }) + debugModuleSlots = next + } + + function debugBarGeometry() { + var out = [] + for (var i = 0; i < debugModuleSlots.length; i++) { + var slot = debugModuleSlots[i] + if (!slot || !slot.activeItem) continue + var point = { x: slot.x, y: slot.y } + try { + point = slot.mapToItem(null, 0, 0) + } catch (e) { + } + out.push({ + id: slot.moduleName, + section: slot.region, + x: Math.round(point.x), + y: Math.round(point.y), + width: Math.round(slot.width), + height: Math.round(slot.height), + visible: slot.visible === true && slot.width > 0 && slot.height > 0, + itemVisible: slot.activeItem.visible === true, + itemWidth: Math.round(slot.activeItem.implicitWidth || 0), + itemHeight: Math.round(slot.activeItem.implicitHeight || 0) + }) + } + return out + } + function targetWindow(target) { return target && target.QsWindow ? target.QsWindow.window : null } @@ -452,10 +492,12 @@ Item { component LeftModules: ModuleList { entries: root.layoutEntries("left") + region: "left" } component RightModules: ModuleList { entries: root.layoutEntries("right") + region: "right" } component CenterModules: Item { @@ -481,12 +523,14 @@ Item { ModuleList { visible: !centerRoot.hasAnchor entries: centerRoot.entries + region: "center" anchors.centerIn: parent } ModuleList { visible: centerRoot.hasAnchor entries: root.entriesBefore(centerRoot.entries, root.centerAnchor) + region: "center" anchors.right: centerAnchorModule.left anchors.verticalCenter: centerAnchorModule.verticalCenter } @@ -495,12 +539,14 @@ Item { id: centerAnchorModule visible: centerRoot.hasAnchor entry: centerRoot.anchorEntry + region: "center" anchors.centerIn: parent } ModuleList { visible: centerRoot.hasAnchor entries: root.entriesAfter(centerRoot.entries, root.centerAnchor) + region: "center" anchors.left: centerAnchorModule.right anchors.verticalCenter: centerAnchorModule.verticalCenter } @@ -518,12 +564,14 @@ Item { ModuleList { visible: !centerRoot.hasAnchor entries: centerRoot.entries + region: "center" anchors.centerIn: parent } ModuleList { visible: centerRoot.hasAnchor entries: root.entriesBefore(centerRoot.entries, root.centerAnchor) + region: "center" anchors.bottom: centerAnchorModule.top anchors.horizontalCenter: centerAnchorModule.horizontalCenter } @@ -532,12 +580,14 @@ Item { id: centerAnchorModule visible: centerRoot.hasAnchor entry: centerRoot.anchorEntry + region: "center" anchors.centerIn: parent } ModuleList { visible: centerRoot.hasAnchor entries: root.entriesAfter(centerRoot.entries, root.centerAnchor) + region: "center" anchors.top: centerAnchorModule.bottom anchors.horizontalCenter: centerAnchorModule.horizontalCenter } @@ -567,6 +617,7 @@ Item { id: moduleListRoot property var entries: [] + property string region: "" visible: entries.length > 0 sourceComponent: root.vertical ? verticalModuleList : horizontalModuleList @@ -585,6 +636,7 @@ Item { ModuleSlot { required property var modelData entry: modelData + region: moduleListRoot.region } } } @@ -602,6 +654,7 @@ Item { ModuleSlot { required property var modelData entry: modelData + region: moduleListRoot.region } } } @@ -612,6 +665,7 @@ Item { id: slot required property var entry + property string region: "" readonly property string moduleName: root.entryId(entry) readonly property var moduleSettings: root.entrySettings(entry) readonly property string customType: root.customModuleType(entry) @@ -638,6 +692,9 @@ Item { width: implicitWidth height: implicitHeight + Component.onCompleted: root.registerDebugModuleSlot(slot) + Component.onDestruction: root.unregisterDebugModuleSlot(slot) + Loader { id: componentLoader active: !slot.qmlCustom && !slot.registered diff --git a/shell/shell.qml b/shell/shell.qml index ed2563d1..b87115dc 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -647,6 +647,10 @@ ShellRoot { return JSON.stringify(shell.shellConfig || {}) } + function debugBarGeometry(): string { + return JSON.stringify(shell.bar && shell.bar.debugBarGeometry ? shell.bar.debugBarGeometry() : []) + } + function summon(id: string, payloadJson: string): string { return shell.summon(id, payloadJson) ? "ok" : "unknown" } diff --git a/test/omarchy-shell-config-test.sh b/test/omarchy-shell-config-test.sh new file mode 100755 index 00000000..5755f56e --- /dev/null +++ b/test/omarchy-shell-config-test.sh @@ -0,0 +1,149 @@ +#!/bin/bash + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +TMPDIR="" + +export PATH="$ROOT/bin:$PATH" + +pass() { + printf 'ok - %s\n' "$1" +} + +fail() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 +} + +cleanup() { + [[ -n $TMPDIR && -d $TMPDIR ]] && rm -rf "$TMPDIR" +} +trap cleanup EXIT + +jq empty "$ROOT/config/omarchy/shell.json" +pass "default shell.json is valid JSON" + +jq -e '.version == 1 and (.bar.layout.left | type == "array") and (.bar.layout.center | type == "array") and (.bar.layout.right | type == "array")' "$ROOT/config/omarchy/shell.json" >/dev/null +pass "default shell.json has versioned bar layout" + +jq -e ' + def ids: map(.id // .); + .bar.layout.center | ids == [ + "omarchy.clock", + "omarchy.weather", + "omarchy.system-update", + "omarchy.indicators" + ] +' "$ROOT/config/omarchy/shell.json" >/dev/null +pass "default center layout keeps update next to weather" + +jq -e ' + (.bar.centerAnchor // "") as $anchor | + any(.bar.layout.center[]; (.id // .) == $anchor) +' "$ROOT/config/omarchy/shell.json" >/dev/null +pass "default center anchor exists in center layout" + +ROOT="$ROOT" python3 <<'PY' +import json +import os +import sys +from pathlib import Path + +root = Path(os.environ["ROOT"]) +config = json.loads((root / "config/omarchy/shell.json").read_text()) +manifests = {} +for manifest_path in (root / "shell/plugins").glob("**/*.manifest.json"): + data = json.loads(manifest_path.read_text()) + manifests[data.get("id", "")] = (manifest_path, data) +for manifest_path in (root / "shell/plugins").glob("**/manifest.json"): + data = json.loads(manifest_path.read_text()) + manifests[data.get("id", "")] = (manifest_path, data) + +entries = [] +for section in ("left", "center", "right"): + entries.extend(config["bar"]["layout"][section]) + +missing = [] +bad = [] +for entry in entries: + widget_id = entry["id"] if isinstance(entry, dict) else str(entry) + if not widget_id.startswith("omarchy."): + continue + + row = manifests.get(widget_id) + if row is None: + missing.append(widget_id) + continue + manifest_path, manifest = row + + if "bar-widget" not in manifest.get("kinds", []): + bad.append(f"{widget_id}: missing bar-widget kind") + entry_point = manifest.get("entryPoints", {}).get("barWidget") + if not entry_point: + bad.append(f"{widget_id}: missing barWidget entry point") + elif not (manifest_path.parent / entry_point).exists(): + bad.append(f"{widget_id}: missing {entry_point}") + +if missing or bad: + for item in missing: + print(f"missing manifest for {item}", file=sys.stderr) + for item in bad: + print(item, file=sys.stderr) + sys.exit(1) +PY +pass "default bar widget ids resolve to manifests and entry points" + +migration=$(grep -rl 'Place the system update indicator next to weather in the bar' "$ROOT/migrations" | head -n 1) +[[ -n $migration ]] || fail "update placement migration exists" + +TMPDIR=$(mktemp -d) +mkdir -p "$TMPDIR/home/.config/omarchy" +cat >"$TMPDIR/home/.config/omarchy/shell.json" <<'JSON' +{ + "version": 1, + "bar": { + "position": "top", + "transparent": false, + "centerAnchor": "omarchy.clock", + "layout": { + "left": [{ "id": "omarchy.menu" }], + "center": [ + { "id": "omarchy.clock", "format": "HH:mm" }, + { "id": "omarchy.weather", "refreshMinutes": 30 }, + { "id": "omarchy.indicators", "items": ["Dnd", "NightLight"] }, + { "id": "omarchy.system-update", "custom": true } + ], + "right": [{ "id": "omarchy.audio" }] + } + }, + "plugins": [{ "id": "custom.plugin", "enabled": true }] +} +JSON + +HOME="$TMPDIR/home" bash "$migration" + +jq -e ' + def ids: map(.id // .); + .bar.layout.center | ids == [ + "omarchy.clock", + "omarchy.weather", + "omarchy.system-update", + "omarchy.indicators" + ] +' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null +pass "update placement migration moves update after weather" + +jq -e ' + .bar.layout.center[1].refreshMinutes == 30 and + .bar.layout.center[2].custom == true and + .bar.layout.center[3].items == ["Dnd", "NightLight"] and + .plugins == [{ "id": "custom.plugin", "enabled": true }] +' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null +pass "update placement migration preserves unrelated settings" + +before=$(sha256sum "$TMPDIR/home/.config/omarchy/shell.json" | awk '{print $1}') +HOME="$TMPDIR/home" bash "$migration" +after=$(sha256sum "$TMPDIR/home/.config/omarchy/shell.json" | awk '{print $1}') +[[ $before == "$after" ]] || fail "update placement migration is idempotent" +pass "update placement migration is idempotent" diff --git a/test/omarchy-shell-qml-contract-test.sh b/test/omarchy-shell-qml-contract-test.sh new file mode 100755 index 00000000..5bd7ce12 --- /dev/null +++ b/test/omarchy-shell-qml-contract-test.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +TMPDIR="" +QS_PID="" + +pass() { + printf 'ok - %s\n' "$1" +} + +fail() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 +} + +cleanup() { + if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then + kill "$QS_PID" 2>/dev/null || true + wait "$QS_PID" 2>/dev/null || true + fi + [[ -n $TMPDIR && -d $TMPDIR ]] && rm -rf "$TMPDIR" +} +trap cleanup EXIT + +if ! command -v quickshell >/dev/null 2>&1; then + pass "quickshell not installed; skipping QML contract test" + exit 0 +fi + +TMPDIR=$(mktemp -d) +result="$TMPDIR/result.json" +log="$TMPDIR/quickshell.log" +config_dir="$TMPDIR/indicator-contract" +mkdir -p "$config_dir" +cp "$ROOT/test/qml/indicator-contract/shell.qml" "$config_dir/shell.qml" +ln -s "$ROOT/shell/Ui" "$config_dir/Ui" +ln -s "$ROOT/shell/Commons" "$config_dir/Commons" + +OMARCHY_PATH="$ROOT" \ +OMARCHY_QML_TEST_RESULT="$result" \ +HOME="$TMPDIR/home" \ +QML2_IMPORT_PATH="$ROOT/shell${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}" \ +QML_IMPORT_PATH="$ROOT/shell${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}" \ +PATH="$ROOT/bin:$PATH" \ + quickshell -p "$config_dir" --no-color >"$log" 2>&1 & +QS_PID=$! + +for _ in {1..50}; do + [[ -s $result ]] && break + if ! kill -0 "$QS_PID" 2>/dev/null; then + sed -n '1,160p' "$log" >&2 + fail "QML contract quickshell exited before writing result" + fi + sleep 0.1 +done + +[[ -s $result ]] || { + sed -n '1,160p' "$log" >&2 + fail "QML contract test timed out" +} + +if ! jq -e '.ok == true' "$result" >/dev/null; then + printf 'QML contract result:\n' >&2 + jq . "$result" >&2 + printf 'QML contract log:\n' >&2 + sed -n '1,160p' "$log" >&2 + fail "QML indicator contract checks pass" +fi + +pass "QML indicator contract checks pass" diff --git a/test/omarchy-shell-runtime-smoke-test.sh b/test/omarchy-shell-runtime-smoke-test.sh new file mode 100755 index 00000000..550a8fb8 --- /dev/null +++ b/test/omarchy-shell-runtime-smoke-test.sh @@ -0,0 +1,99 @@ +#!/bin/bash + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +TMPDIR="" +QS_PID="" + +pass() { + printf 'ok - %s\n' "$1" +} + +fail() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 +} + +cleanup() { + if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then + kill "$QS_PID" 2>/dev/null || true + wait "$QS_PID" 2>/dev/null || true + fi + [[ -n $TMPDIR && -d $TMPDIR ]] && rm -rf "$TMPDIR" +} +trap cleanup EXIT + +if [[ -z ${WAYLAND_DISPLAY:-} ]]; then + pass "no Wayland compositor; skipping shell runtime smoke test" + exit 0 +fi + +if ! command -v quickshell >/dev/null 2>&1; then + pass "quickshell not installed; skipping shell runtime smoke test" + exit 0 +fi + +TMPDIR=$(mktemp -d) +test_root="$TMPDIR/omarchy" +test_home="$TMPDIR/home" +stub_bin="$TMPDIR/bin" +log="$TMPDIR/quickshell.log" +mkdir -p "$test_root" "$test_home" "$stub_bin" +cp -a "$ROOT/shell" "$test_root/shell" +ln -s "$ROOT/config" "$test_root/config" +ln -s "$ROOT/bin" "$test_root/bin" + +cat >"$stub_bin/omarchy-update-available" <<'SH' +#!/bin/bash +echo "Omarchy update available (test)" +exit 0 +SH +chmod +x "$stub_bin/omarchy-update-available" + +OMARCHY_PATH="$test_root" \ +HOME="$test_home" \ +PATH="$stub_bin:$ROOT/bin:$PATH" \ + quickshell -p "$test_root/shell" --no-color >"$log" 2>&1 & +QS_PID=$! + +for _ in {1..80}; do + if OMARCHY_PATH="$test_root" "$ROOT/bin/omarchy-shell" -q shell ping >/dev/null 2>&1; then + break + fi + if ! kill -0 "$QS_PID" 2>/dev/null; then + sed -n '1,200p' "$log" >&2 + fail "test shell exited before IPC became available" + fi + sleep 0.1 +done + +OMARCHY_PATH="$test_root" "$ROOT/bin/omarchy-shell" -q omarchy.system-update refresh >/dev/null 2>&1 || true +sleep 0.8 + +geometry=$(OMARCHY_PATH="$test_root" "$ROOT/bin/omarchy-shell" shell debugBarGeometry) +if [[ -z $geometry ]]; then + sed -n '1,200p' "$log" >&2 + fail "debug bar geometry returned output" +fi + +jq -e ' + map(select(.section == "center" and .visible == true)) as $center | + ($center | map(select(.id == "omarchy.weather" and .visible == true)) | length) >= 1 and + ($center | map(select(.id == "omarchy.system-update" and .visible == true and .width > 0)) | length) >= 1 and + ($center | map(select(.id == "omarchy.indicators" and .visible == true)) | length) >= 1 and + ( + ($center | map(select(.id == "omarchy.weather")) | first | .x) < + ($center | map(select(.id == "omarchy.system-update")) | first | .x) + ) and + ( + ($center | map(select(.id == "omarchy.system-update")) | first | .x) < + ($center | map(select(.id == "omarchy.indicators")) | first | .x) + ) +' <<<"$geometry" >/dev/null || { + printf 'Geometry:\n%s\n' "$geometry" | jq . >&2 + sed -n '1,200p' "$log" >&2 + fail "runtime geometry places visible update between weather and indicators" +} + +pass "runtime geometry places visible update between weather and indicators" diff --git a/test/qml/indicator-contract/shell.qml b/test/qml/indicator-contract/shell.qml new file mode 100644 index 00000000..b381b596 --- /dev/null +++ b/test/qml/indicator-contract/shell.qml @@ -0,0 +1,157 @@ +import QtQuick +import Quickshell + +ShellRoot { + id: root + + property string resultPath: Quickshell.env("OMARCHY_QML_TEST_RESULT") + property var failures: [] + property var commands: [] + + function fail(message) { + failures.push(String(message)) + } + + function assertTrue(condition, message) { + if (!condition) fail(message) + } + + QtObject { + id: notificationService + property bool doNotDisturb: false + function setDoNotDisturb(value) { + doNotDisturb = !!value + } + } + + QtObject { + id: mockShell + function firstPartyServiceFor(id) { + return id === "omarchy.notifications" ? notificationService : null + } + } + + QtObject { + id: mockBar + property bool vertical: false + property int barSize: 26 + property string fontFamily: "monospace" + property var shell: mockShell + function run(command) { + root.commands.push(String(command)) + } + function showTooltip(target, text) {} + function hideTooltip(target) {} + function registerClickTarget(target) {} + function unregisterClickTarget(target) {} + } + + QtObject { + id: indicatorHost + property bool revealInactiveIndicators: true + } + + function createIndicator(name) { + var component = Qt.createComponent("file://" + rootPath + "/shell/plugins/bar/indicators/" + name + ".qml") + if (component.status !== Component.Ready) { + fail(name + " failed to load: " + component.errorString()) + return null + } + + var item = component.createObject(root, { + indicatorHost: indicatorHost, + indicatorBlock: "inactive", + activeOverride: null + }) + if (!item) { + fail(name + " failed to instantiate: " + component.errorString()) + return null + } + return item + } + + function injectBar(item) { + assertTrue(item.bar === null || item.bar === undefined, item.moduleName + " starts without bar") + item.bar = mockBar + assertTrue(item.bar === mockBar, item.moduleName + " accepts delayed bar injection") + } + + function commandCount(command) { + var count = 0 + for (var i = 0; i < commands.length; i++) { + if (commands[i] === command) count++ + } + return count + } + + function writeResult() { + var payload = JSON.stringify({ + ok: failures.length === 0, + failures: failures, + commands: commands, + dnd: notificationService.doNotDisturb + }) + + if (resultPath) { + Quickshell.execDetached(["bash", "-lc", "printf '%s' " + shellQuote(payload) + " > " + shellQuote(resultPath)]) + } + } + + function shellQuote(value) { + return "'" + String(value).replace(/'/g, "'\\''") + "'" + } + + readonly property string rootPath: Quickshell.env("OMARCHY_PATH") + + Timer { + interval: 1 + running: true + repeat: false + onTriggered: { + var dnd = root.createIndicator("Dnd") + if (dnd) { + dnd.moduleName = "Dnd" + root.injectBar(dnd) + notificationService.doNotDisturb = false + dnd.triggerPress(Qt.LeftButton) + root.assertTrue(notificationService.doNotDisturb === true, "DND left click toggles notification service") + } + + var nightLight = root.createIndicator("NightLight") + if (nightLight) { + nightLight.moduleName = "NightLight" + root.injectBar(nightLight) + nightLight.triggerPress(Qt.LeftButton) + root.assertTrue(root.commandCount("omarchy-toggle-nightlight") === 1, "Night Light left click runs toggle command") + } + + var screenRecording = root.createIndicator("ScreenRecording") + if (screenRecording) { + screenRecording.moduleName = "ScreenRecording" + root.injectBar(screenRecording) + screenRecording.triggerPress(Qt.LeftButton) + root.assertTrue(root.commandCount("omarchy-capture-screenrecording") === 1, "Screen Recording left click runs capture command") + } + + var dictation = root.createIndicator("Dictation") + if (dictation) { + dictation.moduleName = "Dictation" + root.injectBar(dictation) + dictation.triggerPress(Qt.LeftButton) + dictation.triggerPress(Qt.RightButton) + root.assertTrue(root.commandCount("omarchy-voxtype-model") === 1, "Dictation left click runs model command") + root.assertTrue(root.commandCount("omarchy-voxtype-config") === 1, "Dictation right click runs config command") + } + + var stayAwake = root.createIndicator("StayAwake") + if (stayAwake) { + stayAwake.moduleName = "StayAwake" + root.injectBar(stayAwake) + stayAwake.triggerPress(Qt.LeftButton) + root.assertTrue(root.commandCount("omarchy-toggle-idle") === 1, "Stay Awake left click runs idle toggle command") + } + + root.writeResult() + } + } +}