diff --git a/modules/bar/workspaces/CompositorData.qml b/modules/bar/workspaces/CompositorData.qml index 3772995d3..29227b3b3 100644 --- a/modules/bar/workspaces/CompositorData.qml +++ b/modules/bar/workspaces/CompositorData.qml @@ -16,8 +16,75 @@ Singleton { property var workspaceOccupationMap: ({}) property var workspaceWindowsMap: ({}) + // Apply a fresh clients array to all derived state. + function _applyClients(clients) { + root.windowList = clients + let tempWinByAddress = {} + for (var i = 0; i < clients.length; ++i) { + var win = clients[i] + if (win && win.address) tempWinByAddress[win.address] = win + } + root.windowByAddress = tempWinByAddress + root.addresses = clients.map((win) => win && win.address) + updateMaps() + } + + // Debounce so a burst of axctl events doesn't trigger many hyprctl forks. + Timer { + id: hyprctlDebounce + interval: 60 + onTriggered: hyprctlClientsProcess.running = true + } + + // Force-refresh windowList by querying hyprctl directly. Necessary because + // axctl's cached state lags behind Hyprland on geometry changes — a + // dispatch like movetoworkspacesilent that triggers re-tiling does not + // get the resulting resizes reflected in the daemon's state until + // something else (e.g. Event.WorkspaceChanged) forces a refresh. So + // chips in the overview keep showing the pre-move sizes even though + // Hyprland has already re-laid-out the windows. We bypass axctl's stale + // cache by polling hyprctl on every axctl event (debounced). function updateWindowList() { - // No-op: state is now pushed inline via axctl subscribe events + hyprctlDebounce.restart() + } + + Process { + id: hyprctlClientsProcess + command: ["hyprctl", "clients", "-j"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + try { + const raw = (text || "").trim() + if (!raw) return + const clients = JSON.parse(raw) + if (!Array.isArray(clients)) return + // hyprctl already produces records with the fields consumers + // expect (address, class, title, workspace.id, monitor, at, + // size, floating, xwayland) — same shape AxctlService produces, + // just sourced directly from Hyprland's live state. + const mapped = clients.map(c => ({ + address: c.address, + class: c.class, + title: c.title, + workspace: { id: (c.workspace && c.workspace.id) || 0, name: (c.workspace && c.workspace.name) || "" }, + monitor: c.monitor || 0, + floating: !!c.floating, + fullscreen: !!c.fullscreen, + hidden: !!c.hidden, + mapped: c.mapped !== false, + at: c.at || [0, 0], + size: c.size || [100, 100], + xwayland: !!c.xwayland, + is_focused: c.focusHistoryID === 0, + focusHistoryID: c.focusHistoryID + })) + root._applyClients(mapped) + } catch (e) { + console.warn("[CompositorData] hyprctl parse failed:", e) + } + } + } } function updateMaps() { @@ -43,16 +110,12 @@ Singleton { Connections { target: AxctlService.clients + // Use axctl events as a "something changed" signal — but treat the + // inline state as untrustworthy for sizes/positions (see comment on + // updateWindowList). Re-fetch from hyprctl for the authoritative + // geometry. function onValuesChanged() { - root.windowList = AxctlService.clients.values - let tempWinByAddress = {} - for (var i = 0; i < root.windowList.length; ++i) { - var win = root.windowList[i] - tempWinByAddress[win.address] = win - } - root.windowByAddress = tempWinByAddress - root.addresses = root.windowList.map((win) => win.address) - updateMaps() + root.updateWindowList() } } diff --git a/modules/services/AxctlService.qml b/modules/services/AxctlService.qml index bade7b388..79683d67d 100644 --- a/modules/services/AxctlService.qml +++ b/modules/services/AxctlService.qml @@ -141,6 +141,9 @@ Singleton { height: mon.height, refreshRate: mon.refresh_rate, scale: mon.scale, + x: parseInt(mon.metadata ? mon.metadata.x : 0) || 0, + y: parseInt(mon.metadata ? mon.metadata.y : 0) || 0, + transform: parseInt(mon.metadata ? mon.metadata.transform : 0) || 0, activeWorkspace: { id: parseInt(mon.metadata ? mon.metadata.active_workspace : 0) || 0, name: mon.metadata ? mon.metadata.active_workspace : "" } })); root.monitors.values = mappedMonitors; diff --git a/modules/widgets/overview/Overview.qml b/modules/widgets/overview/Overview.qml index 6dad0e668..ded832b4c 100644 --- a/modules/widgets/overview/Overview.qml +++ b/modules/widgets/overview/Overview.qml @@ -303,16 +303,19 @@ Item { implicitWidth: workspaceColumnLayout.implicitWidth implicitHeight: workspaceColumnLayout.implicitHeight - // Pre-filter windows for this monitor and workspace group + // Pre-filter windows for the visible workspace group. + // Show windows from ALL monitors in every overview instance — the user can + // then drag windows from any monitor's overview to any workspace cell. + // Each window uses its OWN monitor's metadata for position math (looked up + // per-delegate below) so cross-monitor windows render at correct local coords. readonly property var filteredWindowData: { const minWs = overviewRoot.workspaceGroup * overviewRoot.workspacesShown; const maxWs = (overviewRoot.workspaceGroup + 1) * overviewRoot.workspacesShown; - const monId = overviewRoot.monitorId; const toplevels = ToplevelManager.toplevels.values; return overviewRoot.windowList.filter(win => { const wsId = win?.workspace?.id; - return wsId > minWs && wsId <= maxWs && win.monitor === monId; + return wsId > minWs && wsId <= maxWs; }).map(win => ({ windowData: win, toplevel: (() => { @@ -331,12 +334,19 @@ Item { delegate: OverviewWindow { id: window required property var modelData + // Resolve window's own monitor (may differ from overview's monitor when + // showing cross-monitor windows). Used both for position offset AND scale + // so windows always fit the cell regardless of source monitor size. + readonly property var winMonitor: overviewRoot.monitors.find(m => m.id === modelData.windowData.monitor) ?? overviewRoot.monitorData windowData: modelData.windowData toplevel: modelData.toplevel - scale: overviewRoot.scale + // Per-window scale = cell_width / window_monitor_width — keeps each window + // sized as a thumbnail of its OWN monitor, so a DP-2 window in a DP-1 overview + // fills the cell properly instead of rendering at "0.15 * 1920 in a 2560-sized cell". + scale: (winMonitor && winMonitor.width > 0) ? (overviewRoot.workspaceImplicitWidth / winMonitor.width) : overviewRoot.scale availableWorkspaceWidth: overviewRoot.workspaceImplicitWidth availableWorkspaceHeight: overviewRoot.workspaceImplicitHeight - monitorData: overviewRoot.monitorData + monitorData: winMonitor barPosition: overviewRoot.barPosition barReserved: overviewRoot.barReserved diff --git a/modules/widgets/overview/OverviewWindow.qml b/modules/widgets/overview/OverviewWindow.qml index 8943a6ee7..d1115071a 100644 --- a/modules/widgets/overview/OverviewWindow.qml +++ b/modules/widgets/overview/OverviewWindow.qml @@ -6,6 +6,7 @@ import Quickshell.Wayland import qs.modules.globals import qs.modules.theme import qs.modules.services +import qs.modules.bar.workspaces // For CompositorData import qs.modules.components import qs.config @@ -94,29 +95,36 @@ Item { } } + // Gate animations until after the delegate's initial bindings settle. + // Without this, every rebuild animates from x/y/width/height=0 over the + // full animDuration, making chips appear to "grow into place" — which + // looks like stale/half-sized chips when state churns from window events. + property bool ready: false + Component.onCompleted: ready = true + Behavior on x { - enabled: Config.animDuration > 0 && !root.useOverridePosition + enabled: ready && Config.animDuration > 0 && !root.useOverridePosition NumberAnimation { duration: Config.animDuration easing.type: Easing.OutQuart } } Behavior on y { - enabled: Config.animDuration > 0 && !root.useOverridePosition + enabled: ready && Config.animDuration > 0 && !root.useOverridePosition NumberAnimation { duration: Config.animDuration easing.type: Easing.OutQuart } } Behavior on width { - enabled: Config.animDuration > 0 + enabled: ready && Config.animDuration > 0 NumberAnimation { duration: Config.animDuration easing.type: Easing.OutQuart } } Behavior on height { - enabled: Config.animDuration > 0 + enabled: ready && Config.animDuration > 0 NumberAnimation { duration: Config.animDuration easing.type: Easing.OutQuart @@ -299,6 +307,11 @@ Item { // Check if moving to different workspace if (targetWorkspace !== -1 && targetWorkspace !== windowData?.workspace.id) { + // The monitor whose overview received the drop — pin the target workspace there + // so windows land on the monitor the user dragged within, not on whatever + // monitor the workspace happened to be bound to previously. + const dropMonitorName = overviewRoot.monitor ? overviewRoot.monitor.name : ""; + // Moving to different workspace if (windowData?.floating && (root.x !== root.initX || root.y !== root.initY)) { // Calculate position in the target workspace @@ -307,32 +320,40 @@ Item { const targetRowIndex = Math.floor((targetWorkspace - 1) % overviewRoot.workspacesShown / overviewRoot.columns); const targetXOffset = Math.round((overviewRoot.workspaceImplicitWidth + overviewRoot.workspacePadding + overviewRoot.workspaceSpacing) * targetColIndex + overviewRoot.workspacePadding / 2); const targetYOffset = Math.round((overviewRoot.workspaceImplicitHeight + overviewRoot.workspacePadding + overviewRoot.workspaceSpacing) * targetRowIndex + overviewRoot.workspacePadding / 2); - + // Calculate relative position in target workspace const relativeX = root.x - targetXOffset; const relativeY = root.y - targetYOffset; - + // Convert to percentage const percentageX = Math.round((relativeX / root.availableWorkspaceWidth) * 100); const percentageY = Math.round((relativeY / root.availableWorkspaceHeight) * 100); - + // Move to workspace and set position AxctlService.dispatch(`movetoworkspacesilent ${targetWorkspace}, address:${windowData?.address}`); + if (dropMonitorName) { + AxctlService.dispatch(`moveworkspacetomonitor ${targetWorkspace} ${dropMonitorName}`); + } AxctlService.dispatch(`movewindowpixel exact ${percentageX}% ${percentageY}%, address:${windowData?.address}`); - + // Force immediate window data update CompositorData.updateWindowList(); } else { // Just move workspace without repositioning AxctlService.dispatch(`movetoworkspacesilent ${targetWorkspace}, address:${windowData?.address}`); - + if (dropMonitorName) { + AxctlService.dispatch(`moveworkspacetomonitor ${targetWorkspace} ${dropMonitorName}`); + } + // Force immediate window data update CompositorData.updateWindowList(); } - - // Reset position in overview - root.x = root.initX; - root.y = root.initY; + + // Reset position in overview — use Qt.binding so x/y stay + // reactive to initX/initY (drag.target overrides break the + // original binding; we must restore it). + root.x = Qt.binding(() => root.initX); + root.y = Qt.binding(() => root.initY); } else if (windowData?.floating && (root.x !== root.initX || root.y !== root.initY)) { // Dropped on same workspace and floating - reposition const relativeX = root.x - root.xOffset; @@ -349,19 +370,23 @@ Item { // Force immediate window data update CompositorData.updateWindowList(); - // Set override position for immediate visual update + // Set override position for immediate visual update. + // initX returns overrideX while useOverridePosition is true, + // so binding x to initX keeps it at draggedX until the + // override is cleared, then reactively snaps to the new + // initX once axctl reports the updated window position. root.overrideX = draggedX; root.overrideY = draggedY; root.useOverridePosition = true; - - root.x = draggedX; - root.y = draggedY; - + + root.x = Qt.binding(() => root.initX); + root.y = Qt.binding(() => root.initY); + resetOverrideTimer.restart(); } else { // Reset position for non-floating or non-moved windows - root.x = root.initX; - root.y = root.initY; + root.x = Qt.binding(() => root.initX); + root.y = Qt.binding(() => root.initY); } } } diff --git a/modules/widgets/overview/ScrollingWorkspace.qml b/modules/widgets/overview/ScrollingWorkspace.qml index 3047e5597..68a7da146 100644 --- a/modules/widgets/overview/ScrollingWorkspace.qml +++ b/modules/widgets/overview/ScrollingWorkspace.qml @@ -592,14 +592,17 @@ Item { CompositorData.updateWindowList(); } - // Restore original parent and reset position + // Restore original parent and re-bind position to baseX/baseY. + // initX is a press-time snapshot, so assigning it would freeze + // x at a stale value; Qt.binding keeps the chip reactive to + // subsequent windowData updates. if (windowDelegate.originalParent) { windowDelegate.parent = windowDelegate.originalParent; windowDelegate.originalParent = null; } - windowDelegate.x = windowDelegate.initX; - windowDelegate.y = windowDelegate.initY; - + windowDelegate.x = Qt.binding(() => windowDelegate.baseX); + windowDelegate.y = Qt.binding(() => windowDelegate.baseY); + } else if ((windowDelegate.windowData && windowDelegate.windowData.floating !== undefined ? windowDelegate.windowData.floating : false) && (windowDelegate.x !== windowDelegate.initX || windowDelegate.y !== windowDelegate.initY)) { // Dropped on same workspace and window is floating - reposition it // The window is currently in the drag overlay with global coordinates @@ -653,26 +656,29 @@ Item { windowDelegate.originalParent = null; } - // Set override position for immediate visual update - // Calculate what baseX/baseY should be at the dropped position + // Set override position for immediate visual update. + // baseX returns overrideBaseX while useOverridePosition is + // true, so binding x to baseX keeps it at the dropped + // location until axctl reports the updated position, then + // reactively snaps to the new baseX. windowDelegate.overrideBaseX = relativeX; windowDelegate.overrideBaseY = relativeY; windowDelegate.useOverridePosition = true; - - // Force position to dropped location - windowDelegate.x = relativeX; - windowDelegate.y = relativeY; - + + windowDelegate.x = Qt.binding(() => windowDelegate.baseX); + windowDelegate.y = Qt.binding(() => windowDelegate.baseY); + // Start timer to clear override resetOverrideTimer.restart(); } else { - // Not a floating window or didn't move - restore original parent and position + // Not a floating window or didn't move - restore original parent + // and re-bind position to baseX/baseY (see comment above). if (windowDelegate.originalParent) { windowDelegate.parent = windowDelegate.originalParent; windowDelegate.originalParent = null; } - windowDelegate.x = windowDelegate.initX; - windowDelegate.y = windowDelegate.initY; + windowDelegate.x = Qt.binding(() => windowDelegate.baseX); + windowDelegate.y = Qt.binding(() => windowDelegate.baseY); } root.draggingFromWorkspace = -1;