Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 73 additions & 10 deletions modules/bar/workspaces/CompositorData.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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()
}
}

Expand Down
3 changes: 3 additions & 0 deletions modules/services/AxctlService.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 15 additions & 5 deletions modules/widgets/overview/Overview.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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: (() => {
Expand All @@ -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

Expand Down
65 changes: 45 additions & 20 deletions modules/widgets/overview/OverviewWindow.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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);
}
}
}
Expand Down
34 changes: 20 additions & 14 deletions modules/widgets/overview/ScrollingWorkspace.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down