updated to support workspace options better in aqueous

This commit is contained in:
Zoey committed 2026-09-05 20:55:09 -04:00
1 parent 2ac1fe670c
commit a39196e992
10 files changed
+299 -36

No files matched your search

+45 -22
View File
@@ -5,9 +5,11 @@ This working tree integrates DMS with Aqueous master
no released minimum Aqueous or DMS version has been established.
The tested DMS baseline is `5baef07048656867a374d210d4305911b8a76e4f`, with
common QML `26396ce432d6c71c3f5367438f96f4a8d667e160` plus the working-tree
optional `parentWindow` compatibility fix. The runtime was Arch's
`noctalia-qs 0.0.12`; its version output supplies no source revision.
common QML `26396ce432d6c71c3f5367438f96f4a8d667e160` unchanged. The current
verification uses Quickshell `2d3b3e9c70ef380dff751b61d334dc88df016c29`, built
locally with its existing `parentWindow` API. Earlier runs used Arch's
`noctalia-qs 0.0.12` with temporary popup compatibility edits; those edits have
been removed. See [the implementation audit](aqueous-minimal-change-audit.md).
The Aqueous compositor, CLI and `aqueous-config` helper were built from the same
pinned master. The helper reports 0.7.1, protocol 1. The system-installed older
Aqueous and helper binaries were not used for integration testing.
@@ -121,22 +123,41 @@ For screenshots use `dms screenshot window --seat SEAT` or
active-window capture errors. Selected-output routing still works on empty
workspaces and during layer focus.
WorkspaceSwitcher consumes a shared service-owned view for Aqueous and
ext-workspace. Aqueous rows include authoritative window membership and captured
session identities. Ext rows use native object identity, including when IDs are
absent or names repeat; membership remains unknown rather than being guessed from
Aqueous labels. Padding, labels, activation and wheel navigation share the same
widget path. Aqueous icon grouping, sizing and occupancy use the same eligible
window list, with captured window actions retained through rendering.
WorkspaceSwitcher uses direct Aqueous branches alongside its existing compositor
branches. AqueousService supplies authoritative window/workspace membership and
session identities; the existing ext-workspace path keeps native handles. No
shared workspace adapter or new provider interface is required. Aqueous icons
reuse the widget's existing grouping, cached icon list, sizing and hit-test path,
carrying the captured session with the window ID.
Set `DMS_FORCE_EXTWS=1` in the shell's launch environment to select ext-workspace
even when the Aqueous adapter is healthy. If the protocol is unavailable, the
forced Aqueous workspace view is unavailable. Without the override, a disconnected
forced workspace path is unavailable. Without the override, a disconnected
Aqueous adapter falls back to ext-workspace when available and returns to its
richer model after reconnection. The override changes workspace selection only;
other Aqueous consumers and native overview remain available. Ext activation uses
the protocol's native method and does not expose explicit-seat selection.
Workspace settings expose app icons, follow-monitor-focus, occupied-only filtering,
reverse scrolling and workspace state colors. The app-icon and occupied-only
switches are disabled while the Aqueous model is unavailable or `DMS_FORCE_EXTWS=1`,
because the generic workspace path has no authoritative window membership.
Saved preferences are retained for when the Aqueous model is used again.
The existing workspace rename dialog supports Aqueous:
```sh
dms ipc call workspace-rename open
dms ipc call workspace-rename toggle
dms ipc call workspace-rename close
```
Opening captures the selected seat's active workspace ID and compositor session.
Changing focus while the dialog is open does not redirect the rename. The dialog
closes after an `applied` result and retains the draft on failure. Missing state,
ambiguous seat selection, lock or observation-only policy prevents opening it.
Rename changes runtime state; it does not write persistent configuration.
Native overview controls are available through workspace right-click and:
```sh
@@ -182,18 +203,20 @@ The following passed against the pinned build on 2026-09-05:
switching, overview, active-window crops, helper validation/save/conflicts,
keybind writes, one QML-owned watcher, reconnect after killing the watcher,
orderly logout and process cleanup.
- Shared workspace tests reproduce the prior override, padding and icon-count
failures. The corrected default and forced-ext paths pass real QML widget checks
for activation/wheel navigation, duplicate-name renames, output-following and
independent overview. Default mode additionally checks grouping, padding,
horizontal/vertical sizing, actual icon actions and stale cached actions across
fallback/reconnect. Forced-ext also passed with XWayland capture.
- Workspace tests exercise the established compositor selection, the forced-ext
override, Aqueous padding/grouping/icon counts, native handle identity and
captured sessions. The runtime harness exercises the actual icon hit test,
duplicate-name renames, output following and fallback/reconnect. Settings/dialog
tests cover control visibility and search conditions, forced-ext membership
controls, captured rename targets, retained drafts, lock/stale-target rejection
and late callbacks after reopening. Real UI runs exercise the settings controls
and rename IPC/dialog, including focus changes and correction after failure.
Specialized Niri/Hyprland/Mango/I3-family selection was checked with synthetic inputs;
new live sessions for those compositors were not available for this correction.
The UI runs included the ungrouped taskbar, dock and keyboard widget.
The XWayland DMS run also captured a synthetic XWayland window (632×612), with
The XWayland DMS run also captured a synthetic XWayland window (404×304), with
rotated/fractional native capture at 350×1145 and positive output origins.
Earlier standalone-bar runs produced 632×660 and 350×1205 window crops. The connected
@@ -205,6 +228,7 @@ Reproduce the focused desktop checks with a Pixman-compatible diagnostic build:
```sh
node quickshell/tests/workspace-view.test.mjs
node quickshell/tests/workspace-settings.test.mjs
node quickshell/tests/aqueous-service.test.mjs
python3 scripts/test-aqueous-service.py
node quickshell/tests/display-apply.test.mjs
@@ -212,7 +236,7 @@ node quickshell/tests/aqueous-displays.test.mjs
LD_LIBRARY_PATH=/path/to/patched-wlroots/lib \
python3 scripts/test-aqueous-integration.py \
--aqueous-source /path/to/Aqueous --bin-dir /path/to/matching/binaries
# Add --force-ext to verify the shared widget through ext-workspace.
# Add --force-ext to verify the workspace widget through ext-workspace.
# Add --frame to exercise connected frame reservations.
# Add --xwayland for the XWayland capture check (positive output origins).
```
@@ -232,6 +256,5 @@ a separate release gate. Headless checks do not establish those results.
DankLinux-Docs support tables, packaging and plugin registry publication remain
work for their owning repositories after release dependencies are established.
Keep the generic DPMS/result fixes and optional `parentWindow` compatibility changes
independently reviewable. The common-QML fix is an uncommitted submodule change;
an upstream landing will need a common-QML commit and coordinated dependency pin.
Keep the generic DPMS/result and UWSM logout fixes independently reviewable.
Popup compatibility changes are outside this integration and have been removed.
+4
View File
@@ -1863,6 +1863,8 @@ Item {
function open(): string {
root.workspaceRenameModalLoader.active = true;
if (root.workspaceRenameModalLoader.item) {
if (CompositorService.isAqueous)
return root.workspaceRenameModalLoader.item.show("") ? "WORKSPACE_RENAME_MODAL_OPENED" : "WORKSPACE_RENAME_UNAVAILABLE";
const ws = NiriService.workspaces[NiriService.focusedWorkspaceId];
root.workspaceRenameModalLoader.item.show(ws?.name || "");
return "WORKSPACE_RENAME_MODAL_OPENED";
@@ -1885,6 +1887,8 @@ Item {
root.workspaceRenameModalLoader.item.hide();
return "WORKSPACE_RENAME_MODAL_CLOSED";
}
if (CompositorService.isAqueous)
return root.workspaceRenameModalLoader.item.show("") ? "WORKSPACE_RENAME_MODAL_OPENED" : "WORKSPACE_RENAME_UNAVAILABLE";
const ws = NiriService.workspaces[NiriService.focusedWorkspaceId];
root.workspaceRenameModalLoader.item.show(ws?.name || "");
return "WORKSPACE_RENAME_MODAL_OPENED";
+39 -1
View File
@@ -9,6 +9,8 @@ DankFloatingWindow {
readonly property var log: Log.scoped("WorkspaceRenameModal")
readonly property int inputFieldHeight: Theme.fontSizeMedium + Theme.spacingL * 2
property var aqueousWorkspace: null
property bool renaming: false
objectName: "workspaceRenameModal"
title: I18n.tr("Rename Workspace")
@@ -19,9 +21,24 @@ DankFloatingWindow {
onClosed: hide()
function show(name) {
if (CompositorService.isAqueous) {
if (renaming)
return false;
const workspace = AqueousService.workspaces.find(ws => ws.output === AqueousService.seat?.output && ws.active);
if (!AqueousService.available || !AqueousService.seat || !workspace || !AqueousService.capabilities.commands || AqueousService.locked) {
ToastService.showError(I18n.tr("Error"), I18n.tr("Unavailable"));
return false;
}
aqueousWorkspace = {
id: workspace.id,
session: workspace.aqueousSession
};
name = workspace.name;
}
nameInput.text = name;
visible = true;
Qt.callLater(() => nameInput.forceActiveFocus());
return true;
}
function hide() {
@@ -29,6 +46,24 @@ DankFloatingWindow {
}
function submitAndClose() {
if (aqueousWorkspace || CompositorService.isAqueous) {
if (!aqueousWorkspace || renaming)
return;
const target = aqueousWorkspace;
renaming = true;
AqueousService.command("workspace.rename", {
id: target.id,
session: target.session,
name: nameInput.text
}, success => {
if (root.aqueousWorkspace !== target)
return;
root.renaming = false;
if (success)
root.hide();
});
return;
}
renameWorkspace(nameInput.text);
hide();
}
@@ -49,6 +84,8 @@ DankFloatingWindow {
return;
}
nameInput.text = "";
aqueousWorkspace = null;
renaming = false;
}
FocusScope {
@@ -133,7 +170,7 @@ DankFloatingWindow {
textColor: Theme.surfaceText
placeholderText: I18n.tr("Workspace name")
backgroundColor: "transparent"
enabled: root.visible
enabled: root.visible && !root.renaming
onAccepted: submitAndClose()
}
}
@@ -190,6 +227,7 @@ DankFloatingWindow {
MouseArea {
id: renameArea
enabled: !root.renaming
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
@@ -183,7 +183,7 @@ SettingsCard {
"label": I18n.tr("Custom", "workspace color option")
})]
readonly property bool workspaceStateColorsVisible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango
readonly property bool workspaceStateColorsVisible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isAqueous
readonly property bool urgentWorkspaceColorsVisible: workspaceStateColorsVisible || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle
function isFocusedAppearanceSection(section) {
@@ -1,4 +1,5 @@
import QtQuick
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
@@ -61,7 +62,8 @@ Item {
text: I18n.tr("Show Workspace Apps")
description: I18n.tr("Display application icons in workspace indicators")
checked: SettingsData.showWorkspaceApps
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isAqueous
enabled: !CompositorService.isAqueous || (AqueousService.available && Quickshell.env("DMS_FORCE_EXTWS") !== "1")
onToggled: checked => SettingsData.set("showWorkspaceApps", checked)
}
@@ -153,7 +155,7 @@ Item {
text: I18n.tr("Follow Monitor Focus")
description: I18n.tr("Show workspaces of the currently focused monitor")
checked: SettingsData.workspaceFollowFocus
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle || CompositorService.isAqueous
onToggled: checked => SettingsData.set("workspaceFollowFocus", checked)
}
@@ -163,7 +165,8 @@ Item {
text: I18n.tr("Show Occupied Workspaces Only")
description: I18n.tr("Display only workspaces that contain windows")
checked: SettingsData.showOccupiedWorkspacesOnly
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isAqueous
enabled: !CompositorService.isAqueous || (AqueousService.available && Quickshell.env("DMS_FORCE_EXTWS") !== "1")
onToggled: checked => SettingsData.set("showOccupiedWorkspacesOnly", checked)
}
@@ -173,7 +176,7 @@ Item {
text: I18n.tr("Reverse Scrolling Direction")
description: I18n.tr("Reverse workspace switch direction when scrolling over the bar")
checked: SettingsData.reverseScrolling
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango
visible: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isAqueous
onToggled: checked => SettingsData.set("reverseScrolling", checked)
}
@@ -47,6 +47,7 @@ Singleton {
"isAqueous": () => CompositorService.isAqueous,
"nativeOverviewCapable": () => CompositorService.isNiri || CompositorService.isAqueous,
"smartDockCapable": () => CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isAqueous,
"workspaceFollowFocusCapable": () => CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle || CompositorService.isAqueous,
"isHyprlandOrNiri": () => CompositorService.isHyprland || CompositorService.isNiri,
"windowRulesCapable": () => CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango,
"layoutCapable": () => CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango,
@@ -0,0 +1,109 @@
import assert from "node:assert/strict";
import {readFileSync} from "node:fs";
import vm from "node:vm";
const read = path => readFileSync(new URL(path, import.meta.url), "utf8");
function methods(context, source, names) {
for (const name of names) {
const start = source.indexOf(" function " + name + "(");
assert.notEqual(start, -1);
vm.runInContext(source.slice(start, source.indexOf("\n }", start) + 6), context);
}
}
const settings = read("../Modules/Settings/WorkspacesTab.qml");
function setting(context, key, property) {
const start = settings.indexOf('settingKey: "' + key + '"');
const row = settings.slice(start, settings.indexOf("onToggled:", start));
const expression = row.match(new RegExp(property + ": ([^\\n]+)"))?.[1];
return expression ? vm.runInContext(expression, context) : true;
}
const compositor = {};
let forced = false;
const conditions = vm.createContext({CompositorService: compositor, AqueousService: {available: true}, Quickshell: {env: () => forced ? "1" : "0"}});
const search = read("../Services/SettingsSearchService.qml");
const conditionStart = search.indexOf("readonly property var conditionMap: (") + "readonly property var conditionMap: ".length;
const conditionMap = vm.runInContext(search.slice(conditionStart, search.indexOf("})", conditionStart) + 2), conditions);
const index = JSON.parse(read("../translations/settings_search_index.json"));
for (const backend of ["Aqueous", "Niri", "Hyprland", "Mango", "Sway", "Scroll", "Miracle", "Labwc"]) {
for (const key of Object.keys(compositor)) delete compositor[key];
compositor["is" + backend] = true;
for (const key of ["showWorkspaceApps", "showOccupiedWorkspacesOnly", "reverseScrolling", "workspaceFollowFocus"]) {
const visible = setting(conditions, key, "visible");
const expected = ["Aqueous", "Niri", "Hyprland", "Mango", ...(key === "workspaceFollowFocus" ? ["Sway", "Scroll", "Miracle"] : [])].includes(backend);
assert.equal(!!visible, expected, backend + ": " + key);
assert.equal(!!conditionMap[index.find(e => e.section === key).conditionKey](), expected, "search disagrees with settings");
}
}
for (const key of Object.keys(compositor)) delete compositor[key];
compositor.isAqueous = true;
for (const [available, force, expected] of [[true, false, true], [true, true, false], [false, false, false]]) {
conditions.AqueousService.available = available; forced = force;
for (const key of ["showWorkspaceApps", "showOccupiedWorkspacesOnly"])
assert.equal(setting(conditions, key, "enabled"), expected);
assert.equal(setting(conditions, "workspaceFollowFocus", "enabled"), true);
assert.equal(setting(conditions, "reverseScrolling", "enabled"), true);
}
const appearance = read("../Modules/Settings/WorkspaceAppearanceCard.qml");
assert.equal(vm.runInContext(appearance.match(/workspaceStateColorsVisible: ([^\n]+)/)[1], conditions), true);
const service = vm.createContext({available: true, locked: false, session: "session-a", capabilities: {commands: true},
seat: {id: "seat0", output: "output-a"}, outputs: [], workspaces: [
{kind: "workspace", id: "one", output: "output-a", active: true, name: "Duplicate", aqueousSession: "session-a"},
{kind: "workspace", id: "two", output: "output-b", active: true, name: "Duplicate", aqueousSession: "session-a"}
]});
service.entities = service.workspaces; service.seats = [service.seat];
methods(service, read("../Services/AqueousService.qml"), ["commandArguments", "byteLength"]);
const callbacks = [], commands = [], errors = [], legacy = [];
service.command = (action, fields, callback) => {
try { commands.push(service.commandArguments(action, fields)); callbacks.push(callback); }
catch (error) { errors.push(String(error)); callback(false); }
};
const modal = vm.createContext({CompositorService: {isAqueous: true}, AqueousService: service,
aqueousWorkspace: null, renaming: false, nameInput: {text: "", forceActiveFocus() {}},
Qt: {callLater: fn => fn()}, I18n: {tr: text => text}, ToastService: {showError: (...args) => errors.push(args)},
NiriService: {renameWorkspace: name => legacy.push(name)}, HyprlandService: {renameWorkspace: name => legacy.push(name)}});
modal.root = modal;
const modalSource = read("../Modals/WorkspaceRenameModal.qml");
methods(modal, modalSource, ["show", "hide", "submitAndClose", "renameWorkspace"]);
const visibleStart = modalSource.indexOf(" onVisibleChanged: {") + " onVisibleChanged: ".length;
vm.runInContext("function visibilityChanged() " + modalSource.slice(visibleStart, modalSource.indexOf("\n }", visibleStart) + 6), modal);
let visible = false;
Object.defineProperty(modal, "visible", {get: () => visible, set: value => { visible = value; modal.visibilityChanged(); }});
assert(modal.show("ignored"));
assert.equal(modal.nameInput.text, "Duplicate");
assert.equal(modal.aqueousWorkspace.id, "one");
service.seat.output = "output-b";
modal.nameInput.text = "日本語 🫧";
modal.submitAndClose(); modal.submitAndClose();
assert.equal(commands.length, 1, "double submission");
assert.deepEqual(Array.from(commands[0]), ["aqueousctl", "workspace", "rename", "--id", "one", "--name", "日本語 🫧", "--json"]);
assert(modal.visible && modal.renaming, "closed before command result");
callbacks.shift()(false);
assert(modal.visible && !modal.renaming);
assert.equal(modal.nameInput.text, "日本語 🫧", "failure discarded the draft");
modal.submitAndClose(); callbacks.shift()(true);
assert(!modal.visible && !modal.aqueousWorkspace);
for (const mutate of [() => service.locked = true, () => service.session = "session-b", () => service.entities = [], () => service.capabilities.commands = false, () => service.available = false]) {
Object.assign(service, {available: true, locked: false, session: "session-a", entities: service.workspaces, capabilities: {commands: true}});
assert(modal.show("")); modal.nameInput.text = "Retained draft";
mutate(); const previous = commands.length; modal.submitAndClose();
assert.equal(commands.length, previous);
assert(modal.visible && !modal.renaming);
assert.equal(modal.nameInput.text, "Retained draft");
modal.hide();
}
Object.assign(service, {available: true, locked: false, session: "session-a", entities: service.workspaces, capabilities: {commands: true}});
for (const name of ["bad\nname", "🫧".repeat(257)]) {
assert(modal.show("")); modal.nameInput.text = name; modal.submitAndClose();
assert(modal.visible && !modal.renaming); modal.hide();
}
assert(modal.show("")); modal.nameInput.text = "Old dialog"; modal.submitAndClose();
modal.hide(); assert(modal.show(""));
callbacks.shift()(true);
assert(modal.visible, "late result closed a newly opened dialog"); modal.hide();
service.seat = null; assert.equal(modal.show(""), false); assert(!modal.visible);
modal.CompositorService = {isNiri: true}; assert(modal.show("Niri")); modal.submitAndClose();
modal.CompositorService = {isHyprland: true}; assert(modal.show("Hyprland")); modal.submitAndClose();
assert.deepEqual(legacy, ["Niri", "Hyprland"]);
process.stdout.write("PASS: workspace settings/search, forced-ext controls, captured rename target, failures and existing compositor dispatch\n");
@@ -407,7 +407,9 @@ def find_settings_components(content, filename, wrappers):
visible_raw = extract_property(block, "visible")
condition_key = FILE_CONDITION_MAP.get(filename)
if visible_raw:
if "CompositorService.isAqueous" in visible_raw:
if filename == "WorkspacesTab.qml" and setting_key == "workspaceFollowFocus":
condition_key = "workspaceFollowFocusCapable"
elif "CompositorService.isAqueous" in visible_raw:
if "CompositorService.isHyprland" in visible_raw:
condition_key = "smartDockCapable"
elif "CompositorService.isNiri" in visible_raw:
@@ -1462,7 +1462,7 @@
"workspaces"
],
"description": "Show workspaces of the currently focused monitor",
"conditionKey": "windowRulesCapable"
"conditionKey": "workspaceFollowFocusCapable"
},
{
"section": "workspaceSettings",
@@ -1487,7 +1487,7 @@
],
"icon": "view_module",
"description": "Show workspace index numbers in the top bar workspace switcher",
"conditionKey": "windowRulesCapable"
"conditionKey": "smartDockCapable"
},
{
"section": "groupActiveWorkspaceApps",
@@ -1610,7 +1610,7 @@
"workspace"
],
"description": "Reverse workspace switch direction when scrolling over the bar",
"conditionKey": "windowRulesCapable"
"conditionKey": "smartDockCapable"
},
{
"section": "workspaceUnfocusedMonitorSeparateAppearance",
@@ -1695,7 +1695,7 @@
"workspaces"
],
"description": "Display only workspaces that contain windows",
"conditionKey": "windowRulesCapable"
"conditionKey": "smartDockCapable"
},
{
"section": "showWorkspaceApps",
@@ -1724,7 +1724,7 @@
"workspace"
],
"description": "Display application icons in workspace indicators",
"conditionKey": "windowRulesCapable"
"conditionKey": "smartDockCapable"
},
{
"section": "showWorkspaceIndex",
+85 -2
View File
@@ -204,9 +204,50 @@ def main():
}
return result;
}
function renameModal() {
return dmsShellLoader.item?.children.find(c => c.workspaceRenameModalLoader !== undefined)?.workspaceRenameModalLoader?.item;
}
function renameInput(item) {
if (!item)
return null;
if (item.placeholderText === I18n.tr("Workspace name"))
return item;
for (const child of item.children || []) {
const found = renameInput(child);
if (found)
return found;
}
return null;
}
IpcHandler {
target: "aqueousTest"
function state(): string { return JSON.stringify(AqueousService.state); }
function workspaceSetting(key: string): string {
const row = SettingsSearchService.registeredCards[key]?.item;
return JSON.stringify(row ? {visible: row.visible, enabled: row.enabled, checked: row.checked} : {visible: false, tab: PopoutService.settingsModal?.currentTabIndex, registered: Object.keys(SettingsSearchService.registeredCards)});
}
function toggleWorkspaceSetting(key: string): string {
const row = SettingsSearchService.registeredCards[key]?.item;
if (!row?.visible || !row.enabled)
return "UNAVAILABLE";
row.toggled(!row.checked);
return "TOGGLED";
}
function renameStatus(): string {
const modal = entrypoint.renameModal();
const input = entrypoint.renameInput(modal?.contentItem);
return JSON.stringify(modal ? {visible: modal.visible, pending: modal.renaming,
target: modal.aqueousWorkspace, text: input?.text, inputEnabled: input?.enabled} : null);
}
function renameSubmit(name: string): string {
const modal = entrypoint.renameModal();
const input = entrypoint.renameInput(modal?.contentItem);
if (!modal?.visible || !input)
return "UNAVAILABLE";
input.text = name;
modal.submitAndClose();
return "REQUESTED";
}
function workspaceStatus(): string {
return JSON.stringify({backend: workspaceTest.useAqueous ? "aqueous" : workspaceTest.useExtWorkspace ? "ext" : "none", output: workspaceTest.effectiveScreenName,
rows: workspaceTest.workspaceList.map(w => ({name: w.name, active: w.active, number: w.number,
@@ -281,9 +322,23 @@ def main():
assert len(view["delegates"]) == len(view["rows"]), view
if args.frame:
wait_for(lambda: len(entities("output")) == 2 and all(o["usable_bounds"]["width"] < o["bounds"]["width"] and o["usable_bounds"]["height"] < o["bounds"]["height"] for o in entities("output")))
wait_for(lambda: ipc("settings", "openWith", "typography").startswith("SETTINGS_OPEN_SUCCESS"))
wait_for(lambda: ipc("settings", "openWith", "workspaces").startswith("SETTINGS_OPEN_SUCCESS"))
time.sleep(2)
assert "Type SettingsModal unavailable" not in (base / "dms.log").read_text()
for key in ("showWorkspaceApps", "workspaceFollowFocus", "showOccupiedWorkspacesOnly", "reverseScrolling"):
def setting_state():
value = json.loads(ipc("aqueousTest", "workspaceSetting", key))
(base / "workspace-setting.json").write_text(json.dumps(dict(key=key, state=value), indent=2))
return value
control = wait_for(lambda: (value if (value := setting_state()) and value["visible"] else None))
assert control["enabled"] == (not args.force_ext or key not in ("showWorkspaceApps", "showOccupiedWorkspacesOnly")), (key, control)
if control["enabled"]:
assert ipc("aqueousTest", "toggleWorkspaceSetting", key) == "TOGGLED"
wait_for(lambda: setting_state()["checked"] != control["checked"])
assert ipc("aqueousTest", "toggleWorkspaceSetting", key) == "TOGGLED"
wait_for(lambda: setting_state()["checked"] == control["checked"])
else:
assert ipc("aqueousTest", "toggleWorkspaceSetting", key) == "UNAVAILABLE"
assert ipc("settings", "close") == "SETTINGS_CLOSE_SUCCESS"
windows = [spawn([str(fixture), "window"], f"window-{i}.log") for i in range(2)]
clients = wait_for(lambda: (w if len(w := [w for w in entities("window") if w["app_id"] == "aq-shell-test"]) == 2 else None))
@@ -353,6 +408,34 @@ def main():
assert ipc("aqueous", "overview", "hide", output["name"]) == "OVERVIEW_REQUESTED"
wait_for(lambda: entities("session")[0]["overview_output"] is None)
# Use the real rename IPC/dialog and keep its captured target across a focus change.
def rename_status():
value = json.loads(ipc("aqueousTest", "renameStatus"))
(base / "rename-status.json").write_text(json.dumps(value, indent=2))
return value
target = next(w for w in entities("workspace") if w["output"] == output["id"] and w["active"])
destination = next(w for w in entities("workspace") if w["output"] == output["id"] and w["id"] != target["id"])
assert ipc("workspace-rename", "open") == "WORKSPACE_RENAME_MODAL_OPENED"
draft = wait_for(lambda: (value if (value := rename_status()) and value["visible"] else None))
assert draft["target"] == dict(id=target["id"], session=session), draft
assert draft["text"] == target["name"], draft
command("workspace.activate", id=destination["id"], seat=seat)
assert ipc("aqueousTest", "renameSubmit", "日本語 🫧") == "REQUESTED"
wait_for(lambda: not rename_status()["visible"])
wait_for(lambda: next(w for w in entities("workspace") if w["id"] == target["id"])["name"] == "日本語 🫧")
assert next(w for w in entities("workspace") if w["id"] == destination["id"])["name"] == destination["name"]
assert ipc("workspace-rename", "toggle") == "WORKSPACE_RENAME_MODAL_OPENED"
wait_for(lambda: rename_status()["visible"])
assert rename_status()["target"]["id"] == destination["id"]
invalid_name = "x" * 1025
assert ipc("aqueousTest", "renameSubmit", invalid_name) == "REQUESTED"
draft = rename_status()
assert draft["visible"] and not draft["pending"] and draft["text"] == invalid_name, draft
assert ipc("aqueousTest", "renameSubmit", "Renamed after error") == "REQUESTED"
wait_for(lambda: not rename_status()["visible"])
wait_for(lambda: next(w for w in entities("workspace") if w["id"] == destination["id"])["name"] == "Renamed after error")
command("window.activate", id=window, seat=seat)
def capture(name):
command("window.activate", id=window, seat=seat)
run([str(binaries / "dms"), "screenshot", "window", "--seat", seat, "--no-clipboard", "--no-notify", "--dir", str(base), "--filename", name])
@@ -420,7 +503,7 @@ def main():
os.killpg(dms.pid, signal.SIGTERM)
dms.wait(timeout=10)
assert not any(Path("/proc", pid).exists() for pid in watch_pids), "watcher survived DMS shutdown"
print("PASS: daemon/UI, duplicate identities, keyboard, overview, screenshots, helper conflicts, keybinds, single watcher, workspace widget and orderly exit", flush=True)
print("PASS: daemon/UI, duplicate identities, keyboard, overview, screenshots, helper conflicts, keybinds, single watcher, workspace settings/widget/rename and orderly exit", flush=True)
finally:
for child in reversed(children):
if child.poll() is None: