diff --git a/meson.build b/meson.build index 6c9251d60d..e39b20a42b 100644 --- a/meson.build +++ b/meson.build @@ -1122,6 +1122,7 @@ if build_tests 'math_provider', 'notification_filter', 'notification_history_utf8', + 'niri_workspace_backend', 'path_browse', 'plugin_bindings', 'plugin_catalog', diff --git a/src/compositors/compositor_platform.cpp b/src/compositors/compositor_platform.cpp index 5792f10c03..b39625d634 100644 --- a/src/compositors/compositor_platform.cpp +++ b/src/compositors/compositor_platform.cpp @@ -62,6 +62,23 @@ namespace { constexpr Logger kLog("compositor_platform"); + [[nodiscard]] std::unordered_set + windowIdsFromAssignments(const std::vector& assignments) { + std::unordered_set windowIds; + windowIds.reserve(assignments.size()); + for (const auto& assignment : assignments) { + if (!assignment.windowId.empty()) { + windowIds.insert(assignment.windowId); + } + } + return windowIds; + } + + void + retainToplevelsWithWindowIds(std::vector& windows, const std::unordered_set& windowIds) { + std::erase_if(windows, [&](const ToplevelInfo& window) { return !windowIds.contains(window.identifier); }); + } + [[nodiscard]] const char* valueOrUnset(const char* value) { return value != nullptr && value[0] != '\0' ? value : ""; } @@ -875,11 +892,56 @@ std::vector CompositorPlatform::windowsWithoutAppId(wl_output* out return windows; } +std::vector CompositorPlatform::taskbarWindowsForApp( + const std::string& idLower, const std::string& wmClassLower, wl_output* outputFilter, + const std::unordered_set* allowedNiriWindowIds +) const { + if (compositors::isNiri() && m_wayland.hasExtForeignToplevelList()) { + // Niri exposes its numeric IPC window ID as the ext-foreign-toplevel + // identifier, providing an exact association even for duplicate titles. + auto windows = m_wayland.extWindowsForApp(idLower, wmClassLower); + if (!windows.empty()) { + if (allowedNiriWindowIds != nullptr) { + retainToplevelsWithWindowIds(windows, *allowedNiriWindowIds); + } else { + retainToplevelsWithWindowIds(windows, windowIdsFromAssignments(workspaceWindowAssignments(outputFilter))); + } + } + // Do not fall back to title/app-id matching while one side of the exact + // Niri-id join is still pending. The ext `done` or IPC update will retry. + return windows; + } + return windowsForApp(idLower, wmClassLower, outputFilter); +} + +std::vector CompositorPlatform::taskbarWindowsWithoutAppId( + wl_output* outputFilter, const std::unordered_set* allowedNiriWindowIds +) const { + if (compositors::isNiri() && m_wayland.hasExtForeignToplevelList()) { + auto windows = m_wayland.extWindowsWithoutAppId(); + if (!windows.empty()) { + if (allowedNiriWindowIds != nullptr) { + retainToplevelsWithWindowIds(windows, *allowedNiriWindowIds); + } else { + retainToplevelsWithWindowIds(windows, windowIdsFromAssignments(workspaceWindowAssignments(outputFilter))); + } + } + return windows; + } + return windowsWithoutAppId(outputFilter); +} + void CompositorPlatform::activateToplevel(zwlr_foreign_toplevel_handle_v1* handle) { m_wayland.activateToplevel(handle); } void CompositorPlatform::activateToplevelInfo(const ToplevelInfo& window) { + // Niri's ext-foreign-toplevel identifier is its exact IPC window ID, and + // foreign-toplevel activation does not reliably scroll to the target. + if (compositors::isNiri() && window.extHandle != nullptr && !window.identifier.empty()) { + focusCompositorWindow(window.identifier); + return; + } if (window.handle != nullptr) { activateToplevel(window.handle); return; @@ -900,6 +962,12 @@ void CompositorPlatform::closeToplevelInfo(const ToplevelInfo& window) { closeToplevel(window.handle); return; } + if (compositors::isNiri() && !window.identifier.empty()) { + if (m_workspaceMetadataBackend != nullptr) { + (void)m_workspaceMetadataBackend->closeWindowById(window.identifier); + } + return; + } if (compositors::isKde() && m_kwinActiveWindow != nullptr && m_kwinActiveWindow->isAvailable()) { m_kwinActiveWindow->closeWindow(window.title, window.appId, window.identifier); } diff --git a/src/compositors/compositor_platform.h b/src/compositors/compositor_platform.h index 115664a129..f69cc1c749 100644 --- a/src/compositors/compositor_platform.h +++ b/src/compositors/compositor_platform.h @@ -13,6 +13,7 @@ #include #include #include +#include #include struct wl_output; @@ -104,6 +105,13 @@ class CompositorPlatform { [[nodiscard]] std::vector windowsForApp(const std::string& idLower, const std::string& wmClassLower, wl_output* outputFilter = nullptr) const; [[nodiscard]] std::vector windowsWithoutAppId(wl_output* outputFilter = nullptr) const; + [[nodiscard]] std::vector taskbarWindowsForApp( + const std::string& idLower, const std::string& wmClassLower, wl_output* outputFilter = nullptr, + const std::unordered_set* allowedNiriWindowIds = nullptr + ) const; + [[nodiscard]] std::vector taskbarWindowsWithoutAppId( + wl_output* outputFilter = nullptr, const std::unordered_set* allowedNiriWindowIds = nullptr + ) const; [[nodiscard]] bool containsWlrToplevelHandle(zwlr_foreign_toplevel_handle_v1* handle) const; void activateToplevel(zwlr_foreign_toplevel_handle_v1* handle); void activateToplevelInfo(const ToplevelInfo& window); diff --git a/src/compositors/niri/niri_workspace_backend.cpp b/src/compositors/niri/niri_workspace_backend.cpp index 30bb7d287a..a9183f9c64 100644 --- a/src/compositors/niri/niri_workspace_backend.cpp +++ b/src/compositors/niri/niri_workspace_backend.cpp @@ -318,6 +318,18 @@ std::optional NiriWorkspaceBackend::focusedWindowId() const { return std::to_string(*m_focusedWindowId); } +bool NiriWorkspaceBackend::closeWindowById(const std::string& windowId) { + const auto id = parseUnsigned(windowId); + if (!id.has_value()) { + return false; + } + return m_runtime.requestAction( + nlohmann::json{ + {"CloseWindow", nlohmann::json{{"id", *id}}}, + } + ); +} + void NiriWorkspaceBackend::cleanup() { m_runtime.cleanup(); } void NiriWorkspaceBackend::handleStreamReset() { @@ -481,12 +493,13 @@ bool NiriWorkspaceBackend::handleWindowOpenedOrChanged(const nlohmann::json& pay const bool membershipChanged = existing == m_windows.end() ? (state.workspaceId.has_value() || !state.appId.empty()) : !sameWindowMembership(existing->second, state); + const bool layoutChanged = existing != m_windows.end() && !sameWindowLayout(existing->second, state); m_windows[*id] = state; if (membershipChanged) { recomputeOccupancy(); } - return membershipChanged || focusUpdated; + return membershipChanged || layoutChanged || focusUpdated; } bool NiriWorkspaceBackend::handleWindowLayoutsChanged(const nlohmann::json& payload) { @@ -694,6 +707,10 @@ bool NiriWorkspaceBackend::sameWindowMembership( return true; } +bool NiriWorkspaceBackend::sameWindowLayout(const WindowState& lhs, const WindowState& rhs) noexcept { + return lhs.x == rhs.x && lhs.y == rhs.y; +} + std::optional NiriWorkspaceBackend::parseUnsigned(const std::string& value) { if (value.empty()) { return std::nullopt; diff --git a/src/compositors/niri/niri_workspace_backend.h b/src/compositors/niri/niri_workspace_backend.h index 6914d68bc7..55c27fc7e9 100644 --- a/src/compositors/niri/niri_workspace_backend.h +++ b/src/compositors/niri/niri_workspace_backend.h @@ -41,8 +41,9 @@ class NiriWorkspaceBackend final : public compositors::WorkspaceMetadataBackend, [[nodiscard]] std::unordered_map> appIdsByWorkspace(const std::string& outputName = {}) const override; [[nodiscard]] std::vector workspaceWindows(const std::string& outputName = {}) const override; - bool focusWindowById(const std::string& windowId) override; [[nodiscard]] std::optional focusedWindowId() const override; + bool focusWindowById(const std::string& windowId) override; + bool closeWindowById(const std::string& windowId) override; void cleanup() override; private: @@ -81,6 +82,7 @@ class NiriWorkspaceBackend final : public compositors::WorkspaceMetadataBackend, const std::unordered_map& lhs, const std::unordered_map& rhs ) noexcept; + [[nodiscard]] static bool sameWindowLayout(const WindowState& lhs, const WindowState& rhs) noexcept; [[nodiscard]] static std::optional parseUnsigned(const std::string& value); [[nodiscard]] static std::optional parseLeadingNumber(const std::string& value); [[nodiscard]] static std::string workspaceKey(const WorkspaceState& workspace); diff --git a/src/compositors/workspace_backend.h b/src/compositors/workspace_backend.h index 918e27a55a..5995182342 100644 --- a/src/compositors/workspace_backend.h +++ b/src/compositors/workspace_backend.h @@ -144,13 +144,13 @@ namespace compositors { [[nodiscard]] virtual std::vector workspaceWindows(const std::string& /*outputName*/ = {}) const { return {}; } + [[nodiscard]] virtual std::optional focusedWindowId() const { return std::nullopt; } // Focus a window by its compositor-specific id. Returns true if the backend // handled the request (so the caller can skip other focus paths). Named // distinctly from WorkspaceBackend::focusWindow so backends that implement // both interfaces don't hit a conflicting-return-type override. virtual bool focusWindowById(const std::string& /*windowId*/) { return false; } - // Currently focused compositor window id, when the backend tracks focus. - [[nodiscard]] virtual std::optional focusedWindowId() const { return std::nullopt; } + virtual bool closeWindowById(const std::string& /*windowId*/) { return false; } [[nodiscard]] virtual bool canTrackOverviewState() const noexcept { return false; } [[nodiscard]] virtual bool hasOverviewState() const noexcept { return false; } [[nodiscard]] virtual bool isOverviewOpen() const noexcept { return true; } diff --git a/src/shell/bar/widgets/taskbar_widget.cpp b/src/shell/bar/widgets/taskbar_widget.cpp index 7c7da803c8..c12951009a 100644 --- a/src/shell/bar/widgets/taskbar_widget.cpp +++ b/src/shell/bar/widgets/taskbar_widget.cpp @@ -275,17 +275,23 @@ TaskbarWidget::resolveTask(const std::vector& tasks, TaskRef ref, std return &tasks[ref.index]; } +std::string_view TaskbarWidget::workspaceBindingWindowId(const TaskModel& task) { + return !task.exactCompositorWindowId.empty() ? std::string_view(task.exactCompositorWindowId) + : std::string_view(task.workspaceWindowId); +} + void TaskbarWidget::activateTaskModel(const TaskModel& task) { - if (task.firstHandle != nullptr) { - m_platform.activateToplevel(task.firstHandle); - return; - } - if (compositors::isKde() && (!task.title.empty() || !task.appId.empty())) { - m_platform.activateKdeWindow(task.title, task.appId, task.workspaceWindowId); - return; - } - if (!task.workspaceWindowId.empty()) { - m_platform.focusCompositorWindow(task.workspaceWindowId); + const bool hasCompositorWindow = + compositors::isNiri() ? !task.exactCompositorWindowId.empty() : !task.workspaceWindowId.empty(); + if (task.firstHandle != nullptr + || hasCompositorWindow + || (compositors::isKde() && (!task.title.empty() || !task.appId.empty()))) { + ToplevelInfo window{}; + window.title = task.title; + window.appId = task.appId; + window.identifier = compositors::isNiri() ? task.exactCompositorWindowId : task.workspaceWindowId; + window.handle = task.exactCompositorWindowId.empty() ? task.firstHandle : nullptr; + m_platform.activateToplevelInfo(window); return; } if (!task.workspaceKey.empty()) { @@ -299,8 +305,13 @@ void TaskbarWidget::activateTaskModel(const TaskModel& task) { } void TaskbarWidget::closeTaskModel(const TaskModel& task) { - if (task.firstHandle != nullptr) { - m_platform.closeToplevel(task.firstHandle); + if (task.firstHandle != nullptr || !task.exactCompositorWindowId.empty()) { + m_platform.closeToplevelInfo( + ToplevelInfo{ + .identifier = task.exactCompositorWindowId, + .handle = task.exactCompositorWindowId.empty() ? task.firstHandle : nullptr, + } + ); return; } if (compositors::isKde() && (!task.title.empty() || !task.appId.empty() || !task.workspaceWindowId.empty())) { @@ -411,9 +422,9 @@ void TaskbarWidget::launchDesktopEntry(const TaskModel& task) { } void TaskbarWidget::activateOrLaunchPinned(const TaskModel& task) { - auto windows = m_platform.windowsForApp(task.idLower, task.startupWmClassLower, toplevelOutputFilter()); + auto windows = m_platform.taskbarWindowsForApp(task.idLower, task.startupWmClassLower, toplevelOutputFilter()); if (windows.empty() && !task.appIdLower.empty() && task.appIdLower != task.idLower) { - windows = m_platform.windowsForApp(task.appIdLower, task.startupWmClassLower, toplevelOutputFilter()); + windows = m_platform.taskbarWindowsForApp(task.appIdLower, task.startupWmClassLower, toplevelOutputFilter()); } if (windows.empty()) { launchDesktopEntry(task); @@ -816,7 +827,7 @@ void TaskbarWidget::buildTaskButtons(Renderer& renderer) { } if (data.button == BTN_RIGHT && areaPtr != nullptr - && (current->firstHandle != nullptr || compositors::isKde())) { + && (current->firstHandle != nullptr || !current->exactCompositorWindowId.empty() || compositors::isKde())) { openTaskContextMenu(*current, *areaPtr); } }); @@ -1389,6 +1400,7 @@ void TaskbarWidget::updateModels() { const auto active = m_platform.activeToplevel(); const auto* activeHandle = active.has_value() ? active->handle : nullptr; + const auto focusedCompositorWindowId = m_platform.focusedCompositorWindowId(); wl_output* const topFilter = toplevelOutputFilter(); const auto assignmentMode = m_platform.taskbarAssignmentMode(); @@ -1494,6 +1506,18 @@ void TaskbarWidget::updateModels() { }); } + std::unordered_set currentNiriWindowIds; + const std::unordered_set* allowedNiriWindowIds = nullptr; + if (compositors::isNiri()) { + currentNiriWindowIds.reserve(workspaceAssignments.size()); + for (const auto& assignment : workspaceAssignments) { + if (!assignment.windowId.empty()) { + currentNiriWindowIds.insert(assignment.windowId); + } + } + allowedNiriWindowIds = ¤tNiriWindowIds; + } + std::vector running = m_platform.runningAppIds(topFilter); if (compositors::isHyprland() || compositors::isKde()) { std::unordered_set seenApps(running.begin(), running.end()); @@ -1513,7 +1537,7 @@ void TaskbarWidget::updateModels() { !run.entry.startupWmClass.empty() ? toLower(run.entry.startupWmClass) : run.runningLower; const std::string nameLower = !run.entry.nameLower.empty() ? run.entry.nameLower : run.runningLower; - const auto windows = m_platform.windowsForApp(idLower, startupLower, topFilter); + const auto windows = m_platform.taskbarWindowsForApp(idLower, startupLower, topFilter, allowedNiriWindowIds); for (const auto& window : windows) { const auto handleKey = taskHandleKey(window); if (handleKey == 0 || !processedHandles.insert(handleKey).second) { @@ -1531,7 +1555,10 @@ void TaskbarWidget::updateModels() { task.title = window.title; task.active = activeHandle != nullptr && activeHandle == window.handle; task.firstHandle = window.handle; - if (!window.identifier.empty()) { + if (compositors::isNiri() && window.extHandle != nullptr && !window.identifier.empty()) { + task.workspaceWindowId = window.identifier; + task.exactCompositorWindowId = window.identifier; + } else if (!compositors::isNiri() && !window.identifier.empty()) { task.workspaceWindowId = window.identifier; } task.iconPath = resolveIconPath(run.runningAppId, run.entry.icon); @@ -1541,7 +1568,7 @@ void TaskbarWidget::updateModels() { } // Windows with no app id still get a task keyed by toplevel handle / window id. - for (const auto& window : m_platform.windowsWithoutAppId(topFilter)) { + for (const auto& window : m_platform.taskbarWindowsWithoutAppId(topFilter, allowedNiriWindowIds)) { // Skip anonymous XWayland menu popups (no app id and no title to show). if (window.title.empty()) { continue; @@ -1557,6 +1584,10 @@ void TaskbarWidget::updateModels() { task.title = window.title; task.active = activeHandle != nullptr && activeHandle == window.handle; task.firstHandle = window.handle; + if (compositors::isNiri() && window.extHandle != nullptr && !window.identifier.empty()) { + task.workspaceWindowId = window.identifier; + task.exactCompositorWindowId = window.identifier; + } task.iconPath = resolveIconPath({}, {}); nextTasks.push_back(std::move(task)); } @@ -1609,7 +1640,6 @@ void TaskbarWidget::updateModels() { }); if (compositors::isHyprland()) { - const auto focusedCompositorWindowId = m_platform.focusedCompositorWindowId(); for (auto& task : nextTasks) { ToplevelInfo toplevelInfo{}; toplevelInfo.handle = task.firstHandle; @@ -1870,10 +1900,11 @@ void TaskbarWidget::updateModels() { // (Hyprland address mapping). Unrelated identifiers (e.g. ext-toplevel tokens) // must not block later title matching or icons disappear from workspace groups. for (auto& task : nextTasks) { - if (task.workspaceWindowId.empty() || !task.workspaceKey.empty()) { + const std::string_view bindingWindowId = workspaceBindingWindowId(task); + if (bindingWindowId.empty() || !task.workspaceKey.empty()) { continue; } - const auto index = assignmentIndexForWindowId(task.workspaceWindowId); + const auto index = assignmentIndexForWindowId(bindingWindowId); if (!index.has_value() || used[*index]) { continue; } @@ -1885,8 +1916,9 @@ void TaskbarWidget::updateModels() { } // Bind focused compositor window id to the active toplevel before title match. - if (const auto focusedId = m_platform.focusedCompositorWindowId(); focusedId.has_value()) { - if (const auto index = assignmentIndexForWindowId(*focusedId); index.has_value() && !used[*index]) { + if (focusedCompositorWindowId.has_value()) { + if (const auto index = assignmentIndexForWindowId(*focusedCompositorWindowId); + index.has_value() && !used[*index]) { TaskModel* activeTask = nullptr; for (auto& task : nextTasks) { if (task.active) { @@ -1906,6 +1938,9 @@ void TaskbarWidget::updateModels() { auto assignMatch = [&](TaskModel& task, bool requireTitle, const std::function& extraPredicate) -> bool { + if (!task.exactCompositorWindowId.empty()) { + return false; + } if (const auto existing = assignmentIndexForWindowId(task.workspaceWindowId); existing.has_value()) { if (!used[*existing] && extraPredicate(workspaceAssignments[*existing])) { const auto& assignment = workspaceAssignments[*existing]; @@ -1951,7 +1986,7 @@ void TaskbarWidget::updateModels() { }; for (auto& task : nextTasks) { - if (!task.workspaceKey.empty()) { + if (!task.workspaceKey.empty() || !task.exactCompositorWindowId.empty()) { continue; } const auto previous = previousWorkspaceWindowByHandle.find(task.handleKey); @@ -1975,7 +2010,7 @@ void TaskbarWidget::updateModels() { } for (auto& task : nextTasks) { - if (!task.workspaceKey.empty()) { + if (!task.workspaceKey.empty() || !task.exactCompositorWindowId.empty()) { continue; } const auto previous = previousWorkspaceByHandle.find(task.handleKey); @@ -1988,7 +2023,7 @@ void TaskbarWidget::updateModels() { } for (auto& task : nextTasks) { - if (!task.workspaceKey.empty()) { + if (!task.workspaceKey.empty() || !task.exactCompositorWindowId.empty()) { continue; } const auto previous = previousWorkspaceByHandle.find(task.handleKey); @@ -2008,7 +2043,7 @@ void TaskbarWidget::updateModels() { } for (auto& task : nextTasks) { - if (!task.workspaceKey.empty()) { + if (!task.workspaceKey.empty() || !task.exactCompositorWindowId.empty()) { continue; } // Real compositor window id already present in assignments — leave it alone. @@ -2047,7 +2082,9 @@ void TaskbarWidget::updateModels() { } for (auto& task : nextTasks) { - if (task.workspaceKey.empty() || task.workspaceOrder != std::numeric_limits::max()) { + if (!task.exactCompositorWindowId.empty() + || task.workspaceKey.empty() + || task.workspaceOrder != std::numeric_limits::max()) { continue; } @@ -2083,10 +2120,13 @@ void TaskbarWidget::updateModels() { } // Active indicator follows the focused compositor window id when bound. - if (const auto focusedId = m_platform.focusedCompositorWindowId(); focusedId.has_value()) { + if (focusedCompositorWindowId.has_value()) { TaskModel* focusedTask = nullptr; for (auto& task : nextTasks) { - if (!task.workspaceWindowId.empty() && windowIdsMatch(task.workspaceWindowId, *focusedId)) { + const std::string_view activationWindowId = compositors::isNiri() + ? std::string_view(task.exactCompositorWindowId) + : std::string_view(task.workspaceWindowId); + if (!activationWindowId.empty() && windowIdsMatch(activationWindowId, *focusedCompositorWindowId)) { focusedTask = &task; break; } @@ -2114,12 +2154,14 @@ void TaskbarWidget::updateModels() { std::unordered_set claimedWindowIds; for (std::size_t taskIndex = 0; taskIndex < nextTasks.size(); ++taskIndex) { auto& task = nextTasks[taskIndex]; - if (task.workspaceWindowId.empty()) { + const std::string_view bindingWindowId = workspaceBindingWindowId(task); + if (bindingWindowId.empty()) { continue; } for (std::size_t assignmentIndex = 0; assignmentIndex < workspaceAssignments.size(); ++assignmentIndex) { const auto& assignment = workspaceAssignments[assignmentIndex]; - if (assignment.windowId != task.workspaceWindowId || !matchesApp(task, assignment)) { + if (assignment.windowId != bindingWindowId + || (task.exactCompositorWindowId.empty() && !matchesApp(task, assignment))) { continue; } task.workspaceKey = assignment.workspaceKey; @@ -2156,7 +2198,7 @@ void TaskbarWidget::updateModels() { auto tryClaim = [&](bool requireWorkspace, bool requireTitle) -> bool { for (std::size_t i = 0; i < nextTasks.size(); ++i) { auto& task = nextTasks[i]; - if (orderClaimed[i] || !appMatches(task)) { + if (orderClaimed[i] || !task.exactCompositorWindowId.empty() || !appMatches(task)) { continue; } if (!task.workspaceWindowId.empty() && !currentAssignmentWindowIds.contains(task.workspaceWindowId)) { @@ -2276,6 +2318,10 @@ void TaskbarWidget::updateModels() { task.iconPath = resolveIconPath({}, {}); task.workspaceKey = assignment.workspaceKey; task.workspaceWindowId = assignment.windowId; + if (compositors::isNiri()) { + task.exactCompositorWindowId = assignment.windowId; + task.active = focusedCompositorWindowId.has_value() && assignment.windowId == *focusedCompositorWindowId; + } task.workspaceOrder = i; nextTasks.push_back(std::move(task)); representedWindowIds.insert(assignment.windowId); @@ -2300,7 +2346,8 @@ void TaskbarWidget::updateModels() { for (const auto& appId : *list) { const std::string appLower = toLower(appId); const std::string startupWmClassLower = toLower(appId); - const auto windows = m_platform.windowsForApp(appLower, startupWmClassLower, topFilter); + const auto windows = + m_platform.taskbarWindowsForApp(appLower, startupWmClassLower, topFilter, allowedNiriWindowIds); if (windows.empty()) { continue; } @@ -2481,28 +2528,29 @@ void TaskbarWidget::openTaskContextMenu(const TaskModel& task, InputArea& area) return; } - const auto windows = m_platform.windowsForApp(task.idLower, task.startupWmClassLower, toplevelOutputFilter()); + const auto windows = m_platform.taskbarWindowsForApp(task.idLower, task.startupWmClassLower, toplevelOutputFilter()); m_contextMenuHandles.clear(); - m_contextMenuKdeWindows.clear(); + m_contextMenuInfoWindows.clear(); m_contextMenuPrimaryHandle = task.firstHandle; - m_contextMenuKdePrimary = {}; + m_contextMenuInfoPrimary = {}; const bool kde = compositors::isKde(); - if (kde) { - m_contextMenuKdeWindows = windows; - m_contextMenuKdePrimary = ToplevelInfo{ + const bool infoClose = kde || (compositors::isNiri() && !task.exactCompositorWindowId.empty()); + if (infoClose) { + m_contextMenuInfoWindows = windows; + m_contextMenuInfoPrimary = ToplevelInfo{ .title = task.title, .appId = task.appId, - .identifier = task.workspaceWindowId, - .handle = task.firstHandle, + .identifier = compositors::isNiri() ? task.exactCompositorWindowId : task.workspaceWindowId, + .handle = task.exactCompositorWindowId.empty() ? task.firstHandle : nullptr, }; - for (const auto& window : m_contextMenuKdeWindows) { - if (!task.workspaceWindowId.empty() && window.identifier == task.workspaceWindowId) { - m_contextMenuKdePrimary = window; + for (const auto& window : m_contextMenuInfoWindows) { + if (!m_contextMenuInfoPrimary.identifier.empty() && window.identifier == m_contextMenuInfoPrimary.identifier) { + m_contextMenuInfoPrimary = window; break; } if (task.firstHandle != nullptr && window.handle == task.firstHandle) { - m_contextMenuKdePrimary = window; + m_contextMenuInfoPrimary = window; break; } } @@ -2543,15 +2591,16 @@ void TaskbarWidget::openTaskContextMenu(const TaskModel& task, InputArea& area) } } - const auto kdeCanClose = [](const ToplevelInfo& window) { + const auto infoCanClose = [](const ToplevelInfo& window) { return !window.identifier.empty() || !window.title.empty() || !window.appId.empty(); }; const bool showClose = task.running - && (kde ? (kdeCanClose(m_contextMenuKdePrimary) || !m_contextMenuKdeWindows.empty()) - : !m_contextMenuHandles.empty()); - const bool closePrimaryEnabled = kde ? kdeCanClose(m_contextMenuKdePrimary) : m_contextMenuPrimaryHandle != nullptr; - const std::size_t closeAllCount = kde - ? (m_contextMenuKdeWindows.empty() ? (closePrimaryEnabled ? 1U : 0U) : m_contextMenuKdeWindows.size()) + && (infoClose ? (infoCanClose(m_contextMenuInfoPrimary) || !m_contextMenuInfoWindows.empty()) + : !m_contextMenuHandles.empty()); + const bool closePrimaryEnabled = + infoClose ? infoCanClose(m_contextMenuInfoPrimary) : m_contextMenuPrimaryHandle != nullptr; + const std::size_t closeAllCount = infoClose + ? (m_contextMenuInfoWindows.empty() ? (closePrimaryEnabled ? 1U : 0U) : m_contextMenuInfoWindows.size()) : m_contextMenuHandles.size(); const bool pinAvailable = !m_groupByWorkspace && menuEntry.has_value() && !menuEntry->id.empty(); @@ -2619,7 +2668,7 @@ void TaskbarWidget::openTaskContextMenu(const TaskModel& task, InputArea& area) } m_contextMenuPopup->setShadowConfig(m_configService.config().shell.shadow); m_contextMenuPopup->setOnActivate([this, entryActions, entryAppName, entryWorkingDir, entryTerminal, menuEntry, - isPinned](const ContextMenuControlEntry& entry) { + isPinned, infoClose](const ContextMenuControlEntry& entry) { if (entry.id == -4) { if (menuEntry.has_value()) { setEntryPinned(*menuEntry, !isPinned); @@ -2654,21 +2703,21 @@ void TaskbarWidget::openTaskContextMenu(const TaskModel& task, InputArea& area) return; } if (entry.id == -1) { - if (compositors::isKde()) { - m_platform.closeToplevelInfo(m_contextMenuKdePrimary); + if (infoClose) { + m_platform.closeToplevelInfo(m_contextMenuInfoPrimary); } else if (m_contextMenuPrimaryHandle != nullptr) { m_platform.closeToplevel(m_contextMenuPrimaryHandle); } return; } if (entry.id == -2) { - if (compositors::isKde()) { - if (!m_contextMenuKdeWindows.empty()) { - for (const auto& window : m_contextMenuKdeWindows) { + if (infoClose) { + if (!m_contextMenuInfoWindows.empty()) { + for (const auto& window : m_contextMenuInfoWindows) { m_platform.closeToplevelInfo(window); } } else { - m_platform.closeToplevelInfo(m_contextMenuKdePrimary); + m_platform.closeToplevelInfo(m_contextMenuInfoPrimary); } } else { for (auto* handle : m_contextMenuHandles) { @@ -2812,6 +2861,8 @@ TaskbarWidget::ModelComparison TaskbarWidget::compareModels( || nextTasks[i].active != previousTasks[i].active || nextTasks[i].firstHandle != previousTasks[i].firstHandle || nextTasks[i].workspaceKey != previousTasks[i].workspaceKey + || nextTasks[i].workspaceWindowId != previousTasks[i].workspaceWindowId + || nextTasks[i].exactCompositorWindowId != previousTasks[i].exactCompositorWindowId || nextTasks[i].order != previousTasks[i].order || nextTasks[i].workspaceOrder != previousTasks[i].workspaceOrder || nextTasks[i].handleKey != previousTasks[i].handleKey diff --git a/src/shell/bar/widgets/taskbar_widget.h b/src/shell/bar/widgets/taskbar_widget.h index 1ad70b2999..7585547dc8 100644 --- a/src/shell/bar/widgets/taskbar_widget.h +++ b/src/shell/bar/widgets/taskbar_widget.h @@ -89,6 +89,9 @@ class TaskbarWidget : public Widget { std::string iconPath; std::string workspaceKey; std::string workspaceWindowId; + // Authoritative compositor identity used for actions. Unlike workspaceWindowId, + // this is never rewritten by workspace-placement reconciliation. + std::string exactCompositorWindowId; // Desktop entry id used for pin persistence / launch (empty for unmatched windows). std::string desktopEntryId; std::uint64_t workspaceOrder = std::numeric_limits::max(); @@ -157,6 +160,7 @@ class TaskbarWidget : public Widget { [[nodiscard]] static bool taskInWorkspaceGroup(const TaskModel& task, const WorkspaceModel& ws); [[nodiscard]] static const TaskModel* resolveTask(const std::vector& tasks, TaskRef ref, std::uint64_t currentGeneration); + [[nodiscard]] static std::string_view workspaceBindingWindowId(const TaskModel& task); void activateTaskModel(const TaskModel& task); void closeTaskModel(const TaskModel& task); void applyPinnedMerge(std::vector& tasks); @@ -221,9 +225,10 @@ class TaskbarWidget : public Widget { std::unique_ptr m_contextMenuPopup; std::vector m_contextMenuHandles; zwlr_foreign_toplevel_handle_v1* m_contextMenuPrimaryHandle = nullptr; - // KDE has no wlr foreign-toplevel handles; close targets use title/appId/uuid instead. - std::vector m_contextMenuKdeWindows; - ToplevelInfo m_contextMenuKdePrimary; + // KDE and Niri ext-foreign-toplevel tasks close through ToplevelInfo rather + // than a wlr foreign-toplevel handle. + std::vector m_contextMenuInfoWindows; + ToplevelInfo m_contextMenuInfoPrimary; std::uint64_t m_desktopEntriesVersion = 0; IconResolver m_iconResolver; Signal<>::ScopedConnection m_appIconColorizeConn; diff --git a/src/wayland/ext_foreign_toplevels.cpp b/src/wayland/ext_foreign_toplevels.cpp index 5ec1209b76..3ab3ff841c 100644 --- a/src/wayland/ext_foreign_toplevels.cpp +++ b/src/wayland/ext_foreign_toplevels.cpp @@ -92,7 +92,10 @@ std::vector WaylandExtForeignToplevels::allAppIds() const { std::vector ordered; ordered.reserve(m_handles.size()); for (const auto& [_, state] : m_handles) { - const auto appId = effectiveAppId(state.appId, state.title); + if (!state.ready) { + continue; + } + const auto appId = effectiveAppId(state.committed.appId, state.committed.title); if (!appId.empty()) { ordered.push_back(&state); } @@ -104,7 +107,7 @@ std::vector WaylandExtForeignToplevels::allAppIds() const { std::vector ids; ids.reserve(ordered.size()); for (const auto* state : ordered) { - ids.push_back(effectiveAppId(state->appId, state->title)); + ids.push_back(effectiveAppId(state->committed.appId, state->committed.title)); } return ids; } @@ -118,7 +121,10 @@ WaylandExtForeignToplevels::windowsForApp(const std::string& idLower, const std: std::vector matched; for (const auto& [handle, state] : m_handles) { - const auto appId = effectiveAppId(state.appId, state.title); + if (!state.ready) { + continue; + } + const auto appId = effectiveAppId(state.committed.appId, state.committed.title); if (appId.empty()) { continue; } @@ -133,9 +139,9 @@ WaylandExtForeignToplevels::windowsForApp(const std::string& idLower, const std: MatchedWindow{ .order = state.order, .info = ToplevelInfo{ - .title = state.title, + .title = state.committed.title, .appId = appId, - .identifier = state.identifier, + .identifier = state.committed.identifier, .order = state.order, .handle = nullptr, .extHandle = handle, @@ -161,7 +167,10 @@ std::vector WaylandExtForeignToplevels::windowsWithoutAppId() cons std::vector orphans; for (const auto& [handle, state] : m_handles) { - const auto appId = effectiveAppId(state.appId, state.title); + if (!state.ready) { + continue; + } + const auto appId = effectiveAppId(state.committed.appId, state.committed.title); if (!appId.empty()) { continue; } @@ -169,9 +178,9 @@ std::vector WaylandExtForeignToplevels::windowsWithoutAppId() cons OrphanWindow{ .order = state.order, .info = ToplevelInfo{ - .title = state.title, + .title = state.committed.title, .appId = {}, - .identifier = state.identifier, + .identifier = state.committed.identifier, .order = state.order, .handle = nullptr, .extHandle = handle, @@ -210,19 +219,26 @@ void WaylandExtForeignToplevels::onListFinished() { void WaylandExtForeignToplevels::onHandleClosed(ext_foreign_toplevel_handle_v1* handle) { if (handle != nullptr) { ext_foreign_toplevel_handle_v1_destroy(handle); - m_handles.erase(handle); - notifyChanged(); + removeHandle(handle); } } -void WaylandExtForeignToplevels::onHandleDone(ext_foreign_toplevel_handle_v1* /*handle*/) { notifyChanged(); } +void WaylandExtForeignToplevels::onHandleDone(ext_foreign_toplevel_handle_v1* handle) { + const auto it = m_handles.find(handle); + if (it == m_handles.end()) { + return; + } + it->second.committed = it->second.pending; + it->second.ready = true; + notifyChanged(); +} void WaylandExtForeignToplevels::onHandleTitle(ext_foreign_toplevel_handle_v1* handle, const char* title) { const auto it = m_handles.find(handle); if (it == m_handles.end()) { return; } - it->second.title = StringUtils::windowTitleSingleLine(title != nullptr ? title : ""); + it->second.pending.title = StringUtils::windowTitleSingleLine(title != nullptr ? title : ""); } void WaylandExtForeignToplevels::onHandleAppId(ext_foreign_toplevel_handle_v1* handle, const char* appId) { @@ -230,7 +246,7 @@ void WaylandExtForeignToplevels::onHandleAppId(ext_foreign_toplevel_handle_v1* h if (it == m_handles.end()) { return; } - it->second.appId = appId != nullptr ? appId : ""; + it->second.pending.appId = appId != nullptr ? appId : ""; } void WaylandExtForeignToplevels::onHandleIdentifier(ext_foreign_toplevel_handle_v1* handle, const char* identifier) { @@ -238,7 +254,13 @@ void WaylandExtForeignToplevels::onHandleIdentifier(ext_foreign_toplevel_handle_ if (it == m_handles.end()) { return; } - it->second.identifier = identifier != nullptr ? identifier : ""; + it->second.pending.identifier = identifier != nullptr ? identifier : ""; +} + +void WaylandExtForeignToplevels::removeHandle(ext_foreign_toplevel_handle_v1* handle) { + if (m_handles.erase(handle) > 0) { + notifyChanged(); + } } void WaylandExtForeignToplevels::notifyChanged() { diff --git a/src/wayland/ext_foreign_toplevels.h b/src/wayland/ext_foreign_toplevels.h index 5ceffda932..cd2b15fc2c 100644 --- a/src/wayland/ext_foreign_toplevels.h +++ b/src/wayland/ext_foreign_toplevels.h @@ -28,8 +28,8 @@ class WaylandExtForeignToplevels { [[nodiscard]] std::vector windowsWithoutAppId() const; template void visitExtHandles(Fn&& fn) const { - for (const auto& [handle, _] : m_handles) { - if (handle != nullptr) { + for (const auto& [handle, state] : m_handles) { + if (handle != nullptr && state.ready) { fn(handle); } } @@ -44,14 +44,21 @@ class WaylandExtForeignToplevels { void onHandleIdentifier(ext_foreign_toplevel_handle_v1* handle, const char* identifier); private: - struct ToplevelState { + struct ToplevelProperties { std::string title; std::string appId; std::string identifier; + }; + + struct ToplevelState { + ToplevelProperties pending; + ToplevelProperties committed; std::uint64_t order = 0; + bool ready = false; }; void requestInitialSync(); + void removeHandle(ext_foreign_toplevel_handle_v1* handle); void notifyChanged(); ext_foreign_toplevel_list_v1* m_list = nullptr; diff --git a/src/wayland/wayland_connection.cpp b/src/wayland/wayland_connection.cpp index 565710f111..ee7a6a564f 100644 --- a/src/wayland/wayland_connection.cpp +++ b/src/wayland/wayland_connection.cpp @@ -547,12 +547,17 @@ std::vector WaylandConnection::windowsWithoutAppId(wl_output* outp std::vector WaylandConnection::extWindowsForApp(const std::string& idLower, const std::string& wmClassLower) const { - if ((!compositors::isHyprland() && !compositors::isKde()) || !m_extForeignToplevels.isBound()) { + if ((!compositors::isHyprland() && !compositors::isKde() && !compositors::isNiri()) + || !m_extForeignToplevels.isBound()) { return {}; } return m_extForeignToplevels.windowsForApp(idLower, wmClassLower); } +std::vector WaylandConnection::extWindowsWithoutAppId() const { + return m_extForeignToplevels.isBound() ? m_extForeignToplevels.windowsWithoutAppId() : std::vector{}; +} + bool WaylandConnection::containsWlrToplevelHandle(zwlr_foreign_toplevel_handle_v1* handle) const { return m_toplevelsHandler.containsWlrHandle(handle); } diff --git a/src/wayland/wayland_connection.h b/src/wayland/wayland_connection.h index 64a67508df..e35e8dc659 100644 --- a/src/wayland/wayland_connection.h +++ b/src/wayland/wayland_connection.h @@ -195,6 +195,7 @@ class WaylandConnection { [[nodiscard]] std::vector windowsWithoutAppId(wl_output* outputFilter = nullptr) const; [[nodiscard]] std::vector extWindowsForApp(const std::string& idLower, const std::string& wmClassLower) const; + [[nodiscard]] std::vector extWindowsWithoutAppId() const; [[nodiscard]] bool containsWlrToplevelHandle(zwlr_foreign_toplevel_handle_v1* handle) const; template void visitExtToplevelHandles(Fn&& fn) const { m_extForeignToplevels.visitExtHandles(std::forward(fn)); diff --git a/tests/niri_workspace_backend_test.cpp b/tests/niri_workspace_backend_test.cpp new file mode 100644 index 0000000000..ec80aca60c --- /dev/null +++ b/tests/niri_workspace_backend_test.cpp @@ -0,0 +1,108 @@ +#include "compositors/niri/niri_runtime.h" +#include "compositors/niri/niri_workspace_backend.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +int main() { + const std::string socketPath = "/tmp/noctalia-niri-workspace-test-" + std::to_string(getpid()) + ".sock"; + const int listener = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + assert(listener >= 0); + + sockaddr_un address{}; + address.sun_family = AF_UNIX; + assert(socketPath.size() < sizeof(address.sun_path)); + std::copy(socketPath.begin(), socketPath.end(), address.sun_path); + assert(::bind(listener, reinterpret_cast(&address), sizeof(address)) == 0); + assert(::listen(listener, 1) == 0); + setenv("NIRI_SOCKET", socketPath.c_str(), 1); + + std::string request; + std::jthread server([&]() { + const int eventClient = ::accept4(listener, nullptr, nullptr, SOCK_CLOEXEC); + assert(eventClient >= 0); + char buffer[512]; + const ssize_t eventSize = ::read(eventClient, buffer, sizeof(buffer)); + assert(eventSize > 0); + + const int actionClient = ::accept4(listener, nullptr, nullptr, SOCK_CLOEXEC); + assert(actionClient >= 0); + const ssize_t size = ::read(actionClient, buffer, sizeof(buffer)); + assert(size > 0); + request.assign(buffer, static_cast(size)); + constexpr std::string_view response = "{\"Ok\":null}\n"; + assert(::write(actionClient, response.data(), response.size()) == static_cast(response.size())); + ::close(actionClient); + ::close(eventClient); + }); + + compositors::niri::NiriRuntime runtime; + NiriWorkspaceBackend backend(runtime); + int changes = 0; + backend.setChangeCallback([&]() { ++changes; }); + backend.handleEvent( + "WorkspacesChanged", + {{"workspaces", + { + {{"id", 1}, {"idx", 1}, {"output", "DP-1"}}, + {{"id", 2}, {"idx", 2}, {"output", "DP-1"}}, + }}} + ); + changes = 0; + backend.handleEvent( + "WindowsChanged", + {{"windows", + { + {{"id", 41}, + {"workspace_id", 1}, + {"app_id", "com.mitchellh.ghostty"}, + {"is_focused", true}, + {"layout", {{"pos_in_scrolling_layout", {1, 1}}}}}, + {{"id", 42}, + {"workspace_id", 2}, + {"app_id", "com.mitchellh.ghostty"}, + {"is_focused", false}, + {"layout", {{"pos_in_scrolling_layout", {1, 1}}}}}, + }}} + ); + assert(backend.focusedWindowId() == std::optional("41")); + + backend.handleEvent("WindowFocusChanged", {{"id", 42}}); + assert(backend.focusedWindowId() == std::optional("42")); + assert(changes == 2); + + // A new scrolling-layout position must invalidate taskbar ordering even + // though workspace/app membership is unchanged. + backend.handleEvent( + "WindowOpenedOrChanged", + {{"window", + {{"id", 41}, + {"workspace_id", 1}, + {"app_id", "com.mitchellh.ghostty"}, + {"layout", {{"pos_in_scrolling_layout", {2, 1}}}}}}} + ); + assert(changes == 3); + auto windows = backend.workspaceWindows(); + const auto window41 = std::ranges::find(windows, "41", &WorkspaceWindow::windowId); + assert(window41 != windows.end() && window41->x == 2 && window41->y == 1); + + assert(backend.closeWindowById("42")); + server.join(); + const nlohmann::json expectedRequest{{"Action", {{"CloseWindow", {{"id", 42}}}}}}; + assert(nlohmann::json::parse(request) == expectedRequest); + assert(!backend.closeWindowById("not-an-id")); + + ::close(listener); + ::unlink(socketPath.c_str()); + return 0; +} diff --git a/tests/taskbar_widget_test.cpp b/tests/taskbar_widget_test.cpp index de9565549d..02d6d16928 100644 --- a/tests/taskbar_widget_test.cpp +++ b/tests/taskbar_widget_test.cpp @@ -41,6 +41,39 @@ class TaskbarWidgetTestAccess { .title = std::move(title), }; } + + static std::pair + rebindWorkspaceWindow(std::string exactWindowId, std::string assignedWindowId) { + TaskbarWidget::TaskModel task{ + .workspaceWindowId = exactWindowId, + .exactCompositorWindowId = std::move(exactWindowId), + }; + task.workspaceWindowId = std::move(assignedWindowId); + return {task.workspaceWindowId, task.exactCompositorWindowId}; + } + + static std::string bindingWindowId(std::string workspaceWindowId, std::string exactWindowId) { + return std::string( + TaskbarWidget::workspaceBindingWindowId( + TaskbarWidget::TaskModel{ + .workspaceWindowId = std::move(workspaceWindowId), + .exactCompositorWindowId = std::move(exactWindowId), + } + ) + ); + } + + static bool exactWindowIdChangeKeepsLayout(std::string previousId, std::string nextId) { + const TaskbarWidget::TaskModel previous{ + .handleKey = 11, + .exactCompositorWindowId = std::move(previousId), + }; + const TaskbarWidget::TaskModel next{ + .handleKey = 11, + .exactCompositorWindowId = std::move(nextId), + }; + return TaskbarWidget::compareModels(false, {previous}, {}, {next}, {}).layoutEqual; + } }; int main() { @@ -63,5 +96,14 @@ int main() { assert(!TaskbarWidgetTestAccess::resolvedTitle(tasks, 2, 7, 7).has_value()); assert(!TaskbarWidgetTestAccess::resolvedTitle(tasks, 0, 7, 8).has_value()); + // Workspace placement can be rebound while compositor actions retain the + // authoritative ext-foreign-toplevel identifier. + assert(TaskbarWidgetTestAccess::rebindWorkspaceWindow("41", "42") == std::pair(std::string("42"), std::string("41"))); + // Exact compositor identity also remains authoritative for later workspace + // reconciliation instead of preserving the crossed mutable assignment. + assert(TaskbarWidgetTestAccess::bindingWindowId("42", "41") == "41"); + assert(TaskbarWidgetTestAccess::bindingWindowId("42", "") == "42"); + assert(!TaskbarWidgetTestAccess::exactWindowIdChangeKeepsLayout("41", "42")); + return 0; } diff --git a/tests/wayland_toplevels_identity_test.cpp b/tests/wayland_toplevels_identity_test.cpp index b74ac2c9a7..0b73200425 100644 --- a/tests/wayland_toplevels_identity_test.cpp +++ b/tests/wayland_toplevels_identity_test.cpp @@ -10,6 +10,7 @@ #include #define private public +#include "wayland/ext_foreign_toplevels.h" #include "wayland/wayland_toplevels.h" #undef private @@ -58,5 +59,82 @@ int main() { toplevels.onHandleOutputLeave(handle, otherOutput); assert(it->second.output == nullptr); assert(toplevels.windowsForApp("sample-chat-desktop", "samplechat", someOutput).empty()); + + WaylandExtForeignToplevels extToplevels; + int changeCount = 0; + extToplevels.setChangeCallback([&]() { ++changeCount; }); + + auto* extHandle = reinterpret_cast(0x30); + auto [extIt, extInserted] = + extToplevels.m_handles.try_emplace(extHandle, WaylandExtForeignToplevels::ToplevelState{}); + assert(extInserted); + extIt->second.order = extToplevels.m_nextOrder++; + + extToplevels.onHandleTitle(extHandle, "noctalia"); + extToplevels.onHandleAppId(extHandle, "com.mitchellh.ghostty"); + extToplevels.onHandleIdentifier(extHandle, "49"); + + // Initial properties are an atomic batch and remain hidden until `done`. + assert(extToplevels.allAppIds().empty()); + assert(extToplevels.windowsForApp("com.mitchellh.ghostty", "ghostty").empty()); + std::size_t visited = 0; + extToplevels.visitExtHandles([&](ext_foreign_toplevel_handle_v1*) { ++visited; }); + assert(visited == 0); + + extToplevels.onHandleDone(extHandle); + assert(changeCount == 1); + assert(extToplevels.allAppIds() == std::vector{"com.mitchellh.ghostty"}); + auto extWindows = extToplevels.windowsForApp("com.mitchellh.ghostty", "ghostty"); + assert(extWindows.size() == 1); + assert(extWindows[0].identifier == "49"); + assert(extWindows[0].title == "noctalia"); + visited = 0; + extToplevels.visitExtHandles([&](ext_foreign_toplevel_handle_v1* current) { + assert(current == extHandle); + ++visited; + }); + assert(visited == 1); + + // Later property batches also remain hidden until their own `done`. + extToplevels.onHandleTitle(extHandle, "renamed"); + extToplevels.onHandleIdentifier(extHandle, "50"); + extWindows = extToplevels.windowsForApp("com.mitchellh.ghostty", "ghostty"); + assert(extWindows.size() == 1); + assert(extWindows[0].identifier == "49"); + assert(extWindows[0].title == "noctalia"); + + extToplevels.onHandleDone(extHandle); + assert(changeCount == 2); + extWindows = extToplevels.windowsForApp("com.mitchellh.ghostty", "ghostty"); + assert(extWindows.size() == 1); + assert(extWindows[0].identifier == "50"); + assert(extWindows[0].title == "renamed"); + + // Identical presentation metadata never collapses distinct exact identities. + auto* duplicateHandle = reinterpret_cast(0x31); + auto [duplicateIt, duplicateInserted] = + extToplevels.m_handles.try_emplace(duplicateHandle, WaylandExtForeignToplevels::ToplevelState{}); + assert(duplicateInserted); + duplicateIt->second.order = extToplevels.m_nextOrder++; + extToplevels.onHandleTitle(duplicateHandle, "renamed"); + extToplevels.onHandleAppId(duplicateHandle, "com.mitchellh.ghostty"); + extToplevels.onHandleIdentifier(duplicateHandle, "52"); + extToplevels.onHandleDone(duplicateHandle); + + extWindows = extToplevels.windowsForApp("com.mitchellh.ghostty", "ghostty"); + assert(extWindows.size() == 2); + assert(extWindows[0].identifier == "50"); + assert(extWindows[1].identifier == "52"); + + // State removal is safe for both ready and not-yet-ready handles. + extToplevels.removeHandle(duplicateHandle); + assert(changeCount == 4); + assert(extToplevels.windowsForApp("com.mitchellh.ghostty", "ghostty").size() == 1); + auto* pendingHandle = reinterpret_cast(0x32); + extToplevels.m_handles.try_emplace(pendingHandle, WaylandExtForeignToplevels::ToplevelState{}); + extToplevels.removeHandle(pendingHandle); + assert(changeCount == 5); + assert(!extToplevels.m_handles.contains(pendingHandle)); + return 0; }