From a39196e9927682cc845025ce0cdad95211c86f08 Mon Sep 17 00:00:00 2001 From: Zoey Date: Sat, 5 Sep 2026 20:55:09 -0400 Subject: [PATCH] updated to support workspace options better in aqueous --- docs/aqueous.md | 67 +++++++---- quickshell/DMSShellIPC.qml | 4 + quickshell/Modals/WorkspaceRenameModal.qml | 40 ++++++- .../Settings/WorkspaceAppearanceCard.qml | 2 +- quickshell/Modules/Settings/WorkspacesTab.qml | 11 +- quickshell/Services/SettingsSearchService.qml | 1 + quickshell/tests/workspace-settings.test.mjs | 109 ++++++++++++++++++ .../translations/extract_settings_index.py | 4 +- .../translations/settings_search_index.json | 10 +- scripts/test-aqueous-integration.py | 87 +++++++++++++- 10 files changed, 299 insertions(+), 36 deletions(-) create mode 100644 quickshell/tests/workspace-settings.test.mjs diff --git a/docs/aqueous.md b/docs/aqueous.md index c94bfab75..867377d2c 100644 --- a/docs/aqueous.md +++ b/docs/aqueous.md @@ -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. diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index 4049aa67d..924f339bb 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -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"; diff --git a/quickshell/Modals/WorkspaceRenameModal.qml b/quickshell/Modals/WorkspaceRenameModal.qml index 1941558f3..33825fa3b 100644 --- a/quickshell/Modals/WorkspaceRenameModal.qml +++ b/quickshell/Modals/WorkspaceRenameModal.qml @@ -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 diff --git a/quickshell/Modules/Settings/WorkspaceAppearanceCard.qml b/quickshell/Modules/Settings/WorkspaceAppearanceCard.qml index f7cda2bec..de2c6100b 100644 --- a/quickshell/Modules/Settings/WorkspaceAppearanceCard.qml +++ b/quickshell/Modules/Settings/WorkspaceAppearanceCard.qml @@ -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) { diff --git a/quickshell/Modules/Settings/WorkspacesTab.qml b/quickshell/Modules/Settings/WorkspacesTab.qml index b9da5c0c1..2679e410e 100644 --- a/quickshell/Modules/Settings/WorkspacesTab.qml +++ b/quickshell/Modules/Settings/WorkspacesTab.qml @@ -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) } diff --git a/quickshell/Services/SettingsSearchService.qml b/quickshell/Services/SettingsSearchService.qml index 8fce5028f..9e0881d7e 100644 --- a/quickshell/Services/SettingsSearchService.qml +++ b/quickshell/Services/SettingsSearchService.qml @@ -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, diff --git a/quickshell/tests/workspace-settings.test.mjs b/quickshell/tests/workspace-settings.test.mjs new file mode 100644 index 000000000..2267df360 --- /dev/null +++ b/quickshell/tests/workspace-settings.test.mjs @@ -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"); diff --git a/quickshell/translations/extract_settings_index.py b/quickshell/translations/extract_settings_index.py index bfe4e07fd..dbde8290b 100755 --- a/quickshell/translations/extract_settings_index.py +++ b/quickshell/translations/extract_settings_index.py @@ -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: diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index c4ac7cfd8..be7d0daa0 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -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", diff --git a/scripts/test-aqueous-integration.py b/scripts/test-aqueous-integration.py index 969f37925..cd0c8744b 100644 --- a/scripts/test-aqueous-integration.py +++ b/scripts/test-aqueous-integration.py @@ -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: