From c3866507bebf336b6426a49badf30fec55ad3d68 Mon Sep 17 00:00:00 2001 From: robin <242058117+kanikamaxxing@users.noreply.github.com> Date: Sat, 23 May 2026 22:05:42 +0200 Subject: [PATCH 1/3] overview: render windows correctly on non-origin monitors The mapped monitor object in AxctlService discards mon.metadata.x/y/transform so monitorData.x is undefined for every monitor. OverviewWindow's position math falls back to 0, which works for the monitor at the origin but renders windows on any other monitor outside the thumbnail bounds (the global window x is used without the monitor offset subtracted). Also drops the per-monitor window filter in Overview so each overview shows all windows across monitors, and computes per-window scale from the window's own monitor width so cross-monitor windows fit the cell properly instead of being sized for the overview's monitor. OverviewWindow now also pins the target workspace to the drop monitor after movetoworkspacesilent, so drops on cells in monitor A's overview actually land on monitor A regardless of where the workspace currently lives. And imports qs.modules.bar.workspaces so the existing CompositorData reference on drag finalize stops throwing ReferenceError. Repro for the primary bug: dual-monitor setup with monitors at non-zero offsets. The overview on the second monitor renders all window thumbnails outside their cell because monitorData.x is undefined and global x positions are used as-is. --- modules/services/AxctlService.qml | 3 +++ modules/widgets/overview/Overview.qml | 20 ++++++++++++----- modules/widgets/overview/OverviewWindow.qml | 24 +++++++++++++++------ 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/modules/services/AxctlService.qml b/modules/services/AxctlService.qml index bade7b38..79683d67 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 6dad0e66..ded832b4 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 8943a6ee..4fb266d3 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 @@ -299,6 +300,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,29 +313,35 @@ 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; From 13766acd56c1d95d315cadd64dfb003f99dc88c8 Mon Sep 17 00:00:00 2001 From: Gemini AI Date: Tue, 2 Jun 2026 14:46:31 +0200 Subject: [PATCH 2/3] overview: refresh windowList from hyprctl on every axctl event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CompositorData.updateWindowList() was a no-op, relying on the inline state piggybacked onto every axctl subscribe event. That state is stale for window geometry: when a dispatch like movetoworkspacesilent causes Hyprland to re-tile, the resulting per-window resizes do not get reflected in the axctl daemon's cached state until something else (e.g. Event.WorkspaceChanged) triggers a refresh. The visible symptom in the overview: drag a chip from cell A to cell B and the dispatch happens correctly — the real windows re-tile on the desktop — but every chip keeps showing the pre-move size, leaving white space around each window thumbnail. Closing and reopening the overview fixes it; so does switching monitors (the focus change emits a workspace event which finally flushes the daemon's state). hyprctl clients -j returns the correct sizes the whole time, axctl window list does not. Wire updateWindowList() to actually fetch hyprctl clients -j, mapped to the same record shape AxctlService.applyState produces so other consumers are unaffected. Axctl events are still used as the "something changed" trigger (they fire reliably even when their inline state is stale), but the authoritative geometry comes from hyprctl now. Debounced at 60ms so a burst of events doesn't fork many subprocesses. Co-Authored-By: Claude Opus 4.7 (1M context) --- modules/bar/workspaces/CompositorData.qml | 83 ++++++++++++++++++++--- 1 file changed, 73 insertions(+), 10 deletions(-) diff --git a/modules/bar/workspaces/CompositorData.qml b/modules/bar/workspaces/CompositorData.qml index 3772995d..29227b3b 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() } } From 7df223fc06da157ee1b5b36589d83e82fdb4abd0 Mon Sep 17 00:00:00 2001 From: Gemini AI Date: Tue, 2 Jun 2026 14:46:44 +0200 Subject: [PATCH 3/3] overview: restore Qt.binding after drag, gate animations until ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related chip-update papercuts on top of the hyprctl refresh fix: 1. After a drag/click on a chip, the onReleased handler did root.x = root.initX and root.y = root.initY to snap the chip back into place. In QML, a plain assignment destroys the property binding — so x and y stopped tracking initX/initY forever after, and subsequent windowData updates couldn't move the chip. The MouseArea's drag.target already breaks the binding during drag, so a clean release path has to restore it. Re-bind via Qt.binding(() => root.initX) in all three onReleased branches (cross-workspace move, floating reposition, plain reset) and the same in ScrollingWorkspace's drop handler (bound to baseX/baseY, since initX there is a press-time snapshot rather than a reactive binding). 2. Every chip rebuild (which happens on each axctl event because filteredWindowData returns a fresh array of fresh objects, so the Repeater destroys + recreates delegates) starts with Item-default x/y/width/height = 0, then the bindings kick in and Behavior on each animates 0 -> target over animDuration. With axctl-driven state churn, chips never finished settling — they perpetually looked half-sized with white borders around them. Gate the Behaviors behind a `ready` flag flipped true in Component.onCompleted so the initial assignment skips the animation; subsequent in-place updates still animate. Co-Authored-By: Claude Opus 4.7 (1M context) --- modules/widgets/overview/OverviewWindow.qml | 41 ++++++++++++------- .../widgets/overview/ScrollingWorkspace.qml | 34 ++++++++------- 2 files changed, 47 insertions(+), 28 deletions(-) diff --git a/modules/widgets/overview/OverviewWindow.qml b/modules/widgets/overview/OverviewWindow.qml index 4fb266d3..d1115071 100644 --- a/modules/widgets/overview/OverviewWindow.qml +++ b/modules/widgets/overview/OverviewWindow.qml @@ -95,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 @@ -342,9 +349,11 @@ Item { 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; @@ -361,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 3047e559..68a7da14 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;