Per-monitor workspace list with cross-monitor switching
Show every workspace grouped by the monitor it is pinned to, in physical
order, and let a click change another monitor without moving the pointer.
- Fixed layout of all 20 workspaces instead of stock's occupancy-derived
list, which seeded [1..5] and hard-capped at 10 — 11-20 could never
appear however they were assigned. Grouped as 11-15 | 1-10 | 16-20,
matching the pinning in ~/.config/hypr/workspaces.lua, with dividers
and wider gaps between groups. Workspace 10 renders as "10" rather than
"0" now that two-digit labels are the norm.
- Clicking a workspace that lives on another monitor switches that
monitor without dragging the cursor across. Omarchy sets
cursor.warp_on_change_workspace = 1, so a plain focus({ workspace })
teleports the pointer to whichever screen the workspace is pinned to.
Instead: suppress warping, hop focus to the target monitor, change its
workspace, hop back, restore warping.
This goes through `hyprctl eval` rather than `dispatch` for two
reasons. Hyprland 0.56 parses `hyprctl dispatch` as Lua, so it takes
exactly one dispatcher object — `dispatch workspace 12` fails as
`hl.dispatch(workspace 12)`. And `hyprctl keyword` no longer works at
all under that parser: "keyword can't work with non-legacy parsers.
Use eval." eval takes multiple statements, so the whole sequence runs
as one call instead of five racing ones.
Clicking a workspace on the monitor you are already on keeps stock
behaviour.
The monitor names are specific to this machine (HDMI-A-1 / DP-1 /
HDMI-A-2) and live in workspaceGroups + groupMonitors at the top of the
file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+123
-28
@@ -6,7 +6,24 @@ import qs.Ui
|
||||
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "omarchy.workspaces"
|
||||
moduleName: "pastilhas.workspaces"
|
||||
|
||||
// Workspaces grouped in physical monitor order, mirroring the pinning in
|
||||
// ~/.config/hypr/workspaces.lua:
|
||||
//
|
||||
// HDMI-A-1 (left) 11-15
|
||||
// DP-1 (centre) 1-10
|
||||
// HDMI-A-2 (right) 16-20
|
||||
//
|
||||
// Stock built its list from occupancy and hard-capped it at 10, so 11-20
|
||||
// could never appear no matter how they were assigned. This is a fixed
|
||||
// layout instead: every workspace is always shown, in the order the monitors
|
||||
// physically sit, so the bar reads as a map of the desk.
|
||||
readonly property var workspaceGroups: [
|
||||
[11, 12, 13, 14, 15],
|
||||
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||
[16, 17, 18, 19, 20]
|
||||
]
|
||||
|
||||
function workspaceById(id) {
|
||||
var values = Hyprland.workspaces.values
|
||||
@@ -17,25 +34,73 @@ BarWidget {
|
||||
return null
|
||||
}
|
||||
|
||||
function workspaceIds() {
|
||||
var ids = [1, 2, 3, 4, 5]
|
||||
var values = Hyprland.workspaces.values
|
||||
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var id = values[i].id
|
||||
if (id > 0 && id <= 10 && ids.indexOf(id) === -1) ids.push(id)
|
||||
// Flattened to a single model with separator markers between the groups, so
|
||||
// one Repeater can lay out buttons and dividers in order.
|
||||
function entries() {
|
||||
var out = []
|
||||
for (var g = 0; g < workspaceGroups.length; g++) {
|
||||
if (g > 0) out.push({ separator: true, id: -g })
|
||||
var group = workspaceGroups[g]
|
||||
for (var i = 0; i < group.length; i++) out.push({ separator: false, id: group[i] })
|
||||
}
|
||||
|
||||
ids.sort(function(left, right) { return left - right })
|
||||
return ids
|
||||
return out
|
||||
}
|
||||
|
||||
// Which monitor each group is pinned to, parallel to workspaceGroups above.
|
||||
readonly property var groupMonitors: ["HDMI-A-1", "DP-1", "HDMI-A-2"]
|
||||
|
||||
function monitorForWorkspace(id) {
|
||||
for (var g = 0; g < workspaceGroups.length; g++) {
|
||||
if (workspaceGroups[g].indexOf(id) !== -1) return groupMonitors[g]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Clicking a workspace that lives on another monitor should change what that
|
||||
// monitor shows without dragging the pointer across the desk. A plain
|
||||
// `focus({ workspace })` can't do that: Omarchy sets
|
||||
// cursor.warp_on_change_workspace = 1, so the pointer teleports to whichever
|
||||
// screen the workspace is pinned to.
|
||||
//
|
||||
// So for a cross-monitor click: suppress warping, hop focus to the target
|
||||
// monitor, change its workspace, hop back, restore warping. It goes through
|
||||
// `hyprctl eval` rather than `dispatch` because dispatch takes exactly one
|
||||
// dispatcher object, and because `hyprctl keyword` no longer works under the
|
||||
// Lua parser ("keyword can't work with non-legacy parsers. Use eval.").
|
||||
//
|
||||
// Clicking a workspace on the monitor you're already on keeps stock
|
||||
// behaviour — plain focus, cursor unaffected anyway.
|
||||
function focusWorkspace(id) {
|
||||
if (!root.bar) return
|
||||
root.bar.run("hyprctl dispatch " + Util.shellQuote("hl.dsp.focus({ workspace = \"" + id + "\" })"))
|
||||
|
||||
var target = monitorForWorkspace(id)
|
||||
var monitor = Hyprland.focusedMonitor
|
||||
var current = monitor ? String(monitor.name || "") : ""
|
||||
|
||||
if (target === "" || current === "" || target === current) {
|
||||
root.bar.run("hyprctl dispatch " + Util.shellQuote("hl.dsp.focus({ workspace = \"" + id + "\" })"))
|
||||
return
|
||||
}
|
||||
|
||||
var lua =
|
||||
'hl.config({ cursor = { warp_on_change_workspace = 0, no_warps = true } }) ' +
|
||||
'hl.dispatch(hl.dsp.focus({ monitor = "' + target + '" })) ' +
|
||||
'hl.dispatch(hl.dsp.focus({ workspace = "' + id + '" })) ' +
|
||||
'hl.dispatch(hl.dsp.focus({ monitor = "' + current + '" })) ' +
|
||||
'hl.config({ cursor = { warp_on_change_workspace = 1, no_warps = false } })'
|
||||
|
||||
root.bar.run("hyprctl eval " + Util.shellQuote(lua))
|
||||
}
|
||||
|
||||
readonly property real trailingGap: root.vertical ? 0 : Style.spaceReal(1.5)
|
||||
readonly property color lineColor: root.bar ? root.bar.foreground : Color.foreground
|
||||
|
||||
// Spacing between adjacent workspace buttons, and the extra breathing room
|
||||
// added on each side of a group divider. The divider sits in the same
|
||||
// GridLayout as the buttons, so without this it would inherit the ordinary
|
||||
// inter-button spacing and the three groups would read as one run.
|
||||
readonly property real itemSpacing: Style.space(1)
|
||||
readonly property real groupGap: itemSpacing * 4
|
||||
|
||||
implicitWidth: grid.implicitWidth + trailingGap
|
||||
implicitHeight: grid.implicitHeight
|
||||
@@ -44,28 +109,58 @@ BarWidget {
|
||||
id: grid
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: root.trailingGap
|
||||
columns: root.vertical ? 1 : root.workspaceIds().length
|
||||
columnSpacing: root.vertical ? 0 : Style.space(1)
|
||||
columns: root.vertical ? 1 : root.entries().length
|
||||
columnSpacing: root.vertical ? 0 : root.itemSpacing
|
||||
rowSpacing: root.vertical ? Style.space(2) : 0
|
||||
|
||||
Repeater {
|
||||
model: root.workspaceIds()
|
||||
model: root.entries()
|
||||
|
||||
WidgetButton {
|
||||
required property int modelData
|
||||
Loader {
|
||||
required property var modelData
|
||||
|
||||
readonly property var workspace: root.workspaceById(modelData)
|
||||
readonly property bool occupied: workspace !== null && workspace.toplevels.values.length > 0
|
||||
readonly property bool focused: Hyprland.focusedWorkspace !== null && Hyprland.focusedWorkspace.id === modelData
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
// Widen the gap either side of a divider so the groups read as three
|
||||
// distinct blocks rather than one long row.
|
||||
Layout.leftMargin: (modelData.separator && !root.vertical) ? root.groupGap : 0
|
||||
Layout.rightMargin: (modelData.separator && !root.vertical) ? root.groupGap : 0
|
||||
Layout.topMargin: (modelData.separator && root.vertical) ? root.groupGap : 0
|
||||
Layout.bottomMargin: (modelData.separator && root.vertical) ? root.groupGap : 0
|
||||
sourceComponent: modelData.separator ? separatorComponent : workspaceComponent
|
||||
|
||||
bar: root.bar
|
||||
text: focused ? "\uDB85\uDCFB" : (modelData === 10 ? "0" : String(modelData))
|
||||
opacity: occupied || focused ? 1 : 0.5
|
||||
horizontalMargin: 6
|
||||
verticalPadding: 6
|
||||
fixedWidth: root.vertical ? root.barSize : Style.space(20)
|
||||
fixedHeight: root.barSize
|
||||
onPressed: function() { root.focusWorkspace(modelData) }
|
||||
Component {
|
||||
id: separatorComponent
|
||||
|
||||
Rectangle {
|
||||
implicitWidth: root.vertical ? Math.round(root.barSize * 0.45) : 1
|
||||
implicitHeight: root.vertical ? 1 : Math.round(root.barSize * 0.45)
|
||||
color: root.lineColor
|
||||
opacity: 0.3
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: workspaceComponent
|
||||
|
||||
WidgetButton {
|
||||
readonly property int wsId: modelData.id
|
||||
readonly property var workspace: root.workspaceById(wsId)
|
||||
readonly property bool occupied: workspace !== null && workspace.toplevels.values.length > 0
|
||||
readonly property bool focused: Hyprland.focusedWorkspace !== null && Hyprland.focusedWorkspace.id === wsId
|
||||
|
||||
bar: root.bar
|
||||
// Stock rendered 10 as "0" to keep every label one character. With
|
||||
// 11-20 alongside it, two-digit labels are the norm, so 10 is shown
|
||||
// as itself.
|
||||
text: focused ? "" : String(wsId)
|
||||
opacity: occupied || focused ? 1 : 0.5
|
||||
horizontalMargin: 6
|
||||
verticalPadding: 6
|
||||
fixedWidth: root.vertical ? root.barSize : Style.space(22)
|
||||
fixedHeight: root.barSize
|
||||
onPressed: function() { root.focusWorkspace(wsId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user