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>
168 lines
6.3 KiB
QML
168 lines
6.3 KiB
QML
import QtQuick
|
|
import QtQuick.Layouts
|
|
import Quickshell.Hyprland
|
|
import qs.Commons
|
|
import qs.Ui
|
|
|
|
BarWidget {
|
|
id: root
|
|
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
|
|
for (var i = 0; i < values.length; i++) {
|
|
if (values[i].id === id) return values[i]
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
// 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] })
|
|
}
|
|
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
|
|
|
|
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
|
|
|
|
GridLayout {
|
|
id: grid
|
|
anchors.fill: parent
|
|
anchors.rightMargin: root.trailingGap
|
|
columns: root.vertical ? 1 : root.entries().length
|
|
columnSpacing: root.vertical ? 0 : root.itemSpacing
|
|
rowSpacing: root.vertical ? Style.space(2) : 0
|
|
|
|
Repeater {
|
|
model: root.entries()
|
|
|
|
Loader {
|
|
required property var 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
|
|
|
|
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) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|