feat(Hyprland): add native special workspace scratchpad support

Closes #1000
This commit is contained in:
purian23 committed 2026-09-18 16:21:54 -04:00
1 parent f0040ffcba
commit a3a9fe13eb
16 files changed
+443 -82

No files matched your search

@@ -101,6 +101,11 @@ hl.bind("SUPER + CTRL + I", hl.dsp.window.move({ workspace = "e-1" }))
-- === Workspace Management ===
hl.bind("CTRL + SHIFT + R", hl.dsp.exec_cmd("dms ipc call workspace-rename open"))
-- === Scratchpad ===
hl.bind("SUPER + S", hl.dsp.workspace.toggle_special())
hl.bind("SUPER + SHIFT + S", hl.dsp.window.move({ workspace = "special", follow = false }))
hl.bind("SUPER + CTRL + S", hl.dsp.window.move({ workspace = "+0" }))
-- === Move Workspaces ===
hl.bind("SUPER + SHIFT + Page_Down", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind("SUPER + SHIFT + Page_Up", hl.dsp.window.move({ workspace = "e-1" }))
+1
View File
@@ -386,6 +386,7 @@ const HYPRLAND_ACTIONS = {
{ id: "movetoworkspacesilent +1", label: "Move to Next (silent)" },
{ id: "movetoworkspacesilent -1", label: "Move to Previous (silent)" },
{ id: "togglespecialworkspace", label: "Toggle Special Workspace" },
{ id: "movetoworkspace +0", label: "Move Out of Special Workspace" },
{ id: "focusworkspaceoncurrentmonitor", label: "Focus Workspace on Current Monitor" },
{ id: "renameworkspace", label: "Rename Workspace" }
],
+50 -7
View File
@@ -227,8 +227,39 @@ function hyprlandPersistentWorkspaces(raw, records, screenName, followFocus, min
return filled.sort(hyprlandOrder);
}
function hyprlandWorkspacesForScreen(raw, screenName, followFocus, occupiedOnly, minCount) {
return hyprlandPersistentWorkspaces(raw, hyprlandListedWorkspaces(raw, screenName, followFocus, occupiedOnly), screenName, followFocus, minCount);
function hyprlandSpecialDisplayName(name) {
return name.startsWith("special:") ? name.slice(8) : name;
}
// Hyprland >= 0.56 reports every special workspace with a null id, so specials only match by name
function hyprlandWorkspaceMatches(ws, record) {
if (record.special !== true)
return ws?.id === record.id;
const name = ws?.name ?? "";
return hyprlandSpecialName(name) && hyprlandSpecialDisplayName(name) === record.name;
}
// Hyprland overlays a special workspace on the monitor's regular one, so active comes from the tracked overlay, not ws.active
function hyprlandSpecialWorkspaces(raw, screenName, followFocus, occupiedOnly) {
const perMonitor = !!screenName && !followFocus;
const visible = raw.visibleSpecials ?? {};
const toplevels = raw.toplevels;
const isVisible = normalized => perMonitor ? visible[screenName] === normalized : Object.values(visible).includes(normalized);
const specials = raw.workspaces.filter(ws => hyprlandSpecial(ws) && (!perMonitor || ws.monitor?.name === screenName)).map(ws => {
const name = ws.name ?? "";
return { id: ws.id, idx: null, name: hyprlandSpecialDisplayName(name), output: ws.monitor?.name ?? "", active: isVisible(name === "special" ? "special:special" : name), placeholder: false, urgent: ws.urgent === true, special: true };
}).filter(ws => !occupiedOnly || ws.active || toplevels.some(tl => hyprlandWorkspaceMatches(tl.workspace, ws))).sort((a, b) => a.name.localeCompare(b.name));
// the default scratchpad is always offered so it can be opened before Hyprland has created it
if (!specials.some(ws => ws.name === "special"))
specials.push({ id: null, idx: null, name: "special", output: screenName ?? "", active: isVisible("special:special"), placeholder: false, urgent: false, special: true });
return specials;
}
function hyprlandWorkspacesForScreen(raw, screenName, followFocus, occupiedOnly, minCount, showSpecial) {
const regular = hyprlandPersistentWorkspaces(raw, hyprlandListedWorkspaces(raw, screenName, followFocus, occupiedOnly), screenName, followFocus, minCount);
if (!showSpecial)
return regular;
return regular.concat(hyprlandSpecialWorkspaces(raw, screenName, followFocus, occupiedOnly));
}
function hyprlandScrollWorkspaces(raw, screenName, followFocus) {
@@ -246,11 +277,23 @@ function hyprlandScrollCurrentId(raw, screenName) {
return raw.monitors.find(m => m.name === screenName)?.activeWorkspace?.id ?? 1;
}
function hyprlandVisibleSpecial(raw, monitorName) {
const tracked = raw.visibleSpecials?.[monitorName];
if (!tracked)
return null;
// the overlay event can land before the workspace list refresh, so the tracked name is the OSD's key
const ws = raw.workspaces.find(ws => hyprlandSpecial(ws) && (ws.name === "special" ? "special:special" : ws.name) === tracked);
return { id: ws?.id || tracked, idx: null, name: hyprlandSpecialDisplayName(tracked), output: monitorName, active: true, placeholder: false, special: true };
}
function hyprlandActiveWorkspace(raw, screenName) {
const monitor = raw.monitors.find(m => !screenName || m.name === screenName);
const ws = monitor?.activeWorkspace;
if (!ws)
return null;
const special = hyprlandVisibleSpecial(raw, monitor.name ?? screenName);
if (special)
return special;
const name = ws.name ?? "";
if (hyprlandSpecialName(name))
return null;
@@ -265,16 +308,16 @@ function hyprlandActiveWorkspace(raw, screenName) {
}
function hyprlandWindowsOnWorkspace(windows, workspace, toplevels) {
const workspaceIds = new Map();
const workspaces = new Map();
for (const toplevel of toplevels) {
if (!workspaceIds.has(toplevel.wayland))
workspaceIds.set(toplevel.wayland, toplevel.workspace?.id);
if (!workspaces.has(toplevel.wayland))
workspaces.set(toplevel.wayland, toplevel.workspace);
}
return windows.filter(win => win && workspaceIds.get(win) === workspace.id);
return windows.filter(win => win && hyprlandWorkspaceMatches(workspaces.get(win), workspace));
}
function hyprlandWorkspaceOccupied(toplevels, workspace) {
return toplevels.some(tl => tl.workspace?.id === workspace.id);
return toplevels.some(tl => hyprlandWorkspaceMatches(tl.workspace, workspace));
}
function mangoRecord(index, tag, output) {
@@ -19,6 +19,7 @@ var DEFAULTS = {
groupActiveWorkspaceApps: false,
workspaceFollowFocus: false,
showOccupiedWorkspacesOnly: false,
showSpecialWorkspaces: false,
reverseScrolling: false,
dwlShowAllTags: false,
workspaceActiveAppHighlightEnabled: false,
+33
View File
@@ -1940,6 +1940,39 @@ Item {
target: "workspace-rename"
}
IpcHandler {
function toggle(name: string): string {
if (!CompositorService.isHyprland)
return "SCRATCHPAD_UNSUPPORTED_COMPOSITOR";
CompositorService.toggleSpecialWorkspace(name);
return "SCRATCHPAD_TOGGLED";
}
function move(name: string): string {
if (!CompositorService.isHyprland)
return "SCRATCHPAD_UNSUPPORTED_COMPOSITOR";
const active = ToplevelManager.activeToplevel;
if (!active)
return "SCRATCHPAD_NO_FOCUSED_WINDOW";
CompositorService.moveWindowToSpecial(active, name || "special");
return "SCRATCHPAD_MOVED";
}
function restore(): string {
if (!CompositorService.isHyprland)
return "SCRATCHPAD_UNSUPPORTED_COMPOSITOR";
const active = ToplevelManager.activeToplevel;
if (!active)
return "SCRATCHPAD_NO_FOCUSED_WINDOW";
if (!CompositorService.windowScratchpadName(active))
return "SCRATCHPAD_WINDOW_NOT_IN_SCRATCHPAD";
CompositorService.moveWindowOutOfSpecial(active);
return "SCRATCHPAD_RESTORED";
}
target: "scratchpad"
}
IpcHandler {
function getFocusedWindow() {
const active = ToplevelManager.activeToplevel;
@@ -16,6 +16,7 @@ DankPopout {
readonly property string windowTitle: currentWindow?.title || ""
readonly property string appName: appId ? Paths.getAppName(appId, DesktopEntries.heuristicLookup(Paths.moddedAppId(appId))) : I18n.tr("Unknown")
readonly property int pid: processId
readonly property string scratchpadName: CompositorService.windowScratchpadName(currentWindow)
layerNamespace: "dms:focused-window-popout"
popupWidth: 340
@@ -177,6 +178,79 @@ DankPopout {
color: Theme.outlineVariant
}
Item {
visible: root.scratchpadName !== ""
width: parent.width
height: BarMetrics.menuRowHeight
Row {
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
spacing: Theme.spacingS
DankIcon {
name: "outbox"
size: Theme.iconSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Move out of scratchpad")
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
anchors.verticalCenter: parent.verticalCenter
}
}
StateLayer {
cornerRadius: BarMetrics.menuItemRadius
onClicked: {
CompositorService.moveWindowOutOfSpecial(root.currentWindow);
root.close();
}
}
}
Repeater {
model: root.scratchpadName !== "" ? [] : CompositorService.specialWorkspaceNames
Item {
required property string modelData
width: parent.width
height: BarMetrics.menuRowHeight
Row {
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
spacing: Theme.spacingS
DankIcon {
name: "inbox"
size: Theme.iconSizeSmall
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: modelData === "special" ? I18n.tr("Move to scratchpad") : I18n.tr("Move to scratchpad: %1", "%1 is the named special workspace").arg(modelData)
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
anchors.verticalCenter: parent.verticalCenter
}
}
StateLayer {
cornerRadius: BarMetrics.menuItemRadius
onClicked: {
CompositorService.moveWindowToSpecial(root.currentWindow, modelData);
root.close();
}
}
}
}
Item {
visible: CompositorService.supportsWindowRules
width: parent.width
@@ -503,6 +503,22 @@ BasePill {
targetWindow.minimized = true;
}
});
const scratchpad = CompositorService.windowScratchpadName(currentWindow);
if (scratchpad)
items.push({
type: "item",
icon: "outbox",
text: I18n.tr("Move out of scratchpad"),
action: () => CompositorService.moveWindowOutOfSpecial(windowContextMenu.currentWindow)
});
for (const name of scratchpad ? [] : CompositorService.specialWorkspaceNames) {
items.push({
type: "item",
icon: "inbox",
text: name === "special" ? I18n.tr("Move to scratchpad") : I18n.tr("Move to scratchpad: %1", "%1 is the named special workspace").arg(name),
action: () => CompositorService.moveWindowToSpecial(windowContextMenu.currentWindow, name)
});
}
items.push({
type: "item",
icon: "close",
@@ -97,7 +97,8 @@ BasePill {
const baseList = CompositorService.workspacesForScreen(root.screenName, root.opt("workspaceFollowFocus"), {
"occupiedOnly": root.opt("showOccupiedWorkspacesOnly"),
"showAllTags": root.opt("dwlShowAllTags"),
"minCount": root.opt("showWorkspacePadding") ? root.opt("workspacePaddingCount") : 0
"minCount": root.opt("showWorkspacePadding") ? root.opt("workspacePaddingCount") : 0,
"showSpecial": root.opt("showSpecialWorkspaces")
});
if (CompositorService.ephemeralWorkspaces)
return hyprlandSlotList(baseList);
@@ -178,7 +179,7 @@ BasePill {
}
function hyprlandSlotList(raw) {
return raw.map(ws => _hyprSlot(ws.id > 0 ? ws.id : "name:" + (ws.name ?? ""), ws));
return raw.map(ws => _hyprSlot(ws.id > 0 ? ws.id : (ws.special ? "special:" : "name:") + (ws.name ?? ""), ws));
}
// Stable placeholder instances so ScriptModel (identity-diffed) reuses padding delegates instead of recreating them on workspace churn
@@ -242,7 +243,7 @@ BasePill {
if (!data || data.placeholder)
return;
if (root.useNativeWorkspaces) {
CompositorService.switchToWorkspace(data);
CompositorService.switchToWorkspace(data, root.effectiveScreenName);
return;
}
if (root.useExtWorkspace && typeof data.activate === "function")
@@ -307,7 +308,8 @@ BasePill {
}
if (!useNativeWorkspaces)
return;
CompositorService.stepWorkspace(getRealWorkspaces(), root.currentWorkspace, direction);
// specials are overlays you toggle, not positions you scroll to
CompositorService.stepWorkspace(getRealWorkspaces().map(ws => root.recordOf(ws)).filter(ws => ws.special !== true), root.currentWorkspace, direction);
}
function getWorkspaceIndexFallback(modelData, index) {
@@ -563,7 +565,13 @@ BasePill {
const name = record?.name;
if (!name)
return null;
return SettingsData.getWorkspaceNameIcon(name);
const custom = SettingsData.getWorkspaceNameIcon(name);
if (custom || record.special !== true)
return custom;
return {
"type": "icon",
"value": "inbox"
};
}
readonly property bool loadedHasIcon: loadedIconData !== null
property var loadedIcons: []
@@ -81,69 +81,22 @@ Column {
checked: root.page.value("showOccupiedWorkspacesOnly")
onToggled: checked => root.page.set("showOccupiedWorkspacesOnly", checked)
}
SettingsToggleRow {
resetStore: root.page
resetKeys: ["showSpecialWorkspaces"]
text: I18n.tr("Show scratchpads")
description: I18n.tr("Special workspaces appear last; click to show or hide")
visible: CompositorService.isHyprland
checked: root.page.value("showSpecialWorkspaces")
onToggled: checked => root.page.set("showSpecialWorkspaces", checked)
}
}
WorkspaceAppearanceCard {
store: root.page.store
}
SettingsCard {
title: I18n.tr("Icons")
settingKey: "workspaceIcons"
visible: NiriService.hasNamedWorkspaces()
Repeater {
model: NiriService.getNamedWorkspaces()
SettingsRow {
required property string modelData
title: modelData
DankIconPicker {
id: iconPicker
anchors.verticalCenter: parent.verticalCenter
Component.onCompleted: {
const iconData = SettingsData.getWorkspaceNameIcon(modelData);
if (iconData)
setIcon(iconData.value, iconData.type);
}
onIconSelected: (iconName, iconType) => {
SettingsData.setWorkspaceNameIcon(modelData, {
"type": iconType,
"value": iconName
});
setIcon(iconName, iconType);
}
Connections {
target: SettingsData
function onWorkspaceIconsUpdated() {
const iconData = SettingsData.getWorkspaceNameIcon(modelData);
if (iconData) {
iconPicker.setIcon(iconData.value, iconData.type);
return;
}
iconPicker.setIcon("", "icon");
}
}
}
DankActionButton {
buttonSize: Theme.iconButtonSize
iconName: "close"
Accessible.name: I18n.tr("Remove")
iconSize: Theme.iconSizeMedium
iconColor: Theme.error
anchors.verticalCenter: parent.verticalCenter
onClicked: SettingsData.removeWorkspaceNameIcon(modelData)
}
}
}
}
SettingsCard {
title: I18n.tr("Advanced")
settingKey: "workspaceAdvanced"
@@ -47,7 +47,7 @@ SettingsCard {
readonly property var borderColorOptions: colorOptions(["surfaceText", "primary", "primaryContainer", "secondary", "secondaryContainer", "tertiary", "tertiaryContainer", "custom"])
function isFocusedAppearanceSection(section) {
return ["workspaceAppearance", "workspaceColorMode", "workspaceOccupiedColorMode", "workspaceUnfocusedColorMode", "workspaceUrgentColorMode", "workspaceFocusedBorderEnabled", "workspaceFocusedBorderColor", "workspaceFocusedBorderThickness"].includes(section);
return ["workspaceAppearance", "workspaceIcons", "workspaceColorMode", "workspaceOccupiedColorMode", "workspaceUnfocusedColorMode", "workspaceUrgentColorMode", "workspaceFocusedBorderEnabled", "workspaceFocusedBorderColor", "workspaceFocusedBorderThickness"].includes(section);
}
SettingsRow {
@@ -197,4 +197,65 @@ SettingsCard {
borderCustomColorKey: "workspaceUnfocusedMonitorBorderCustomColor"
borderThicknessKey: "workspaceUnfocusedMonitorBorderThickness"
}
readonly property var namedWorkspaces: NiriService.getNamedWorkspaces().concat(CompositorService.specialWorkspaceNames)
SettingsRow {
visible: root.namedWorkspaces.length > 0
settingKey: "workspaceIcons"
tags: ["workspace", "icon", "named", "scratchpad", "special"]
title: I18n.tr("Icons")
subtitle: I18n.tr("Named workspaces and scratchpads")
}
Repeater {
model: root.namedWorkspaces
SettingsRow {
required property string modelData
title: modelData
DankIconPicker {
id: iconPicker
anchors.verticalCenter: parent.verticalCenter
Component.onCompleted: {
const iconData = SettingsData.getWorkspaceNameIcon(modelData);
if (iconData)
setIcon(iconData.value, iconData.type);
}
onIconSelected: (iconName, iconType) => {
SettingsData.setWorkspaceNameIcon(modelData, {
"type": iconType,
"value": iconName
});
setIcon(iconName, iconType);
}
Connections {
target: SettingsData
function onWorkspaceIconsUpdated() {
const iconData = SettingsData.getWorkspaceNameIcon(modelData);
if (iconData) {
iconPicker.setIcon(iconData.value, iconData.type);
return;
}
iconPicker.setIcon("", "icon");
}
}
}
DankActionButton {
buttonSize: Theme.iconButtonSize
iconName: "close"
Accessible.name: I18n.tr("Remove")
iconSize: Theme.iconSizeMedium
iconColor: Theme.error
anchors.verticalCenter: parent.verticalCenter
onClicked: SettingsData.removeWorkspaceNameIcon(modelData)
}
}
}
}
@@ -52,7 +52,7 @@ Scope {
HyprlandFocusGrab {
id: grab
windows: [root]
windows: overviewLoader.item?.windowMenuWindow ? [root, overviewLoader.item.windowMenuWindow] : [root]
active: false
property bool hasBeenActivated: false
onActiveChanged: {
@@ -13,6 +13,37 @@ Item {
readonly property HyprlandMonitor monitor: Hyprland.monitorFor(panelWindow.screen)
readonly property real dpr: CompositorService.getScreenScale(panelWindow.screen)
readonly property int workspacesShown: SettingsData.overviewRows * SettingsData.overviewColumns
readonly property alias windowMenuWindow: windowMenu.contextWindow
function openWindowMenu(item, x, y) {
const address = item.windowData?.address;
if (!address || CompositorService.specialWorkspaceNames.length === 0)
return;
windowMenu.targetWindow = address;
const pos = item.mapToGlobal(x, y);
windowMenu.open(panelWindow.screen, pos.x - (panelWindow.screen?.x || 0), pos.y - (panelWindow.screen?.y || 0), false);
}
// scratchpad windows are not in the grid, so the menu only ever offers a move in
DankContextMenu {
id: windowMenu
property string targetWindow: ""
layerNamespace: "dms:overview-window-context-menu"
menuItems: CompositorService.specialWorkspaceNames.map(name => ({
type: "item",
icon: "inbox",
text: name === "special" ? I18n.tr("Move to scratchpad") : I18n.tr("Move to scratchpad: %1", "%1 is the named special workspace").arg(name),
action: () => {
CompositorService.moveWindowToSpecial(windowMenu.targetWindow, name);
Qt.callLater(() => {
Hyprland.refreshToplevels();
Hyprland.refreshWorkspaces();
});
}
}))
}
readonly property var allWorkspaces: Hyprland.workspaces?.values || []
readonly property var allWorkspaceIds: {
@@ -456,6 +487,13 @@ Item {
}
}
}
// on top of dragArea so a right press never starts a drag
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onClicked: mouse => root.openWindowMenu(window, mouse.x, mouse.y)
}
}
}
}
+83 -2
View File
@@ -831,6 +831,73 @@ Singleton {
return true;
}
readonly property var specialWorkspaceNames: {
if (!isHyprland)
return [];
const names = ["special"];
for (const ws of Hyprland.workspaces?.values || []) {
if (!WorkspaceModel.hyprlandSpecial(ws))
continue;
const name = WorkspaceModel.hyprlandSpecialDisplayName(ws.name ?? "");
if (!names.includes(name))
names.push(name);
}
return names;
}
function hyprlandAddressFor(window) {
return isHyprland ? (_hyprlandToplevelFor(window)?.address ?? "") : "";
}
// IPC callers pass Hyprland's own names, prefixed or not
function _scratchpadName(name) {
const bare = WorkspaceModel.hyprlandSpecialDisplayName(String(name ?? ""));
return !bare || bare === "special" ? "special" : bare;
}
function toggleSpecialWorkspace(name) {
if (!isHyprland)
return;
const bare = _scratchpadName(name);
HyprlandService.toggleSpecial(bare === "special" ? "" : bare);
}
function _hyprlandToplevelFor(window) {
if (!window)
return null;
const byAddress = typeof window === "string";
for (const t of Hyprland.toplevels?.values || []) {
if (byAddress ? t.address === window : t.wayland === window)
return t;
}
return null;
}
function windowScratchpadName(window) {
if (!isHyprland)
return "";
const t = _hyprlandToplevelFor(window);
const ws = String(t?.lastIpcObject?.workspace?.name || t?.workspace?.name || "");
return WorkspaceModel.hyprlandSpecialName(ws) ? WorkspaceModel.hyprlandSpecialDisplayName(ws) : "";
}
// "+0" is Hyprland's own way out but resolves against the focused monitor, so prefer the window's monitor when known
function moveWindowOutOfSpecial(window) {
const t = _hyprlandToplevelFor(window);
if (!t?.address)
return;
const target = t.monitor?.activeWorkspace?.id;
HyprlandService.moveToWorkspace(target > 0 ? target : "+0", t.address, true);
}
function moveWindowToSpecial(window, name) {
const address = hyprlandAddressFor(window);
if (!address)
return;
const bare = _scratchpadName(name);
HyprlandService.moveToWorkspace(bare === "special" ? "special" : "special:" + bare, address, false);
}
function hyprlandVisibleSpecialWorkspaceOnScreen(screenOrName) {
const screenName = _screenName(screenOrName);
if (!isHyprland || !screenName)
@@ -1520,6 +1587,9 @@ Singleton {
},
get workspaceRules() {
return HyprlandService.workspaceRules;
},
get visibleSpecials() {
return root.hyprlandVisibleSpecialWorkspaces;
}
};
}
@@ -1581,7 +1651,7 @@ Singleton {
case "niri":
return WorkspaceModel.niriWorkspacesForScreen(_niriWorkspaceState(), screenName, followFocus, options.occupiedOnly, _workspaceRecords);
case "hyprland":
return WorkspaceModel.hyprlandWorkspacesForScreen(_hyprlandWorkspaceState(), screenName, followFocus, options.occupiedOnly, options.minCount);
return WorkspaceModel.hyprlandWorkspacesForScreen(_hyprlandWorkspaceState(), screenName, followFocus, options.occupiedOnly, options.minCount, options.showSpecial);
case "mango":
{
const name = _followedScreen(screenName, followFocus);
@@ -1627,6 +1697,8 @@ Singleton {
case "mango":
case "aqueous":
return record.active === true;
case "hyprland":
return record.special === true ? record.active === true : record.id === currentKey;
default:
return _workspaceKey(record) === currentKey;
}
@@ -1699,6 +1771,8 @@ Singleton {
case "scroll":
case "miracle":
return WorkspaceModel.i3WorkspaceFocused(_i3WorkspaceState(), record);
case "hyprland":
return isCurrentWorkspace(record, currentKey);
default:
return record.id === currentKey;
}
@@ -1725,7 +1799,7 @@ Singleton {
}
}
function switchToWorkspace(record) {
function switchToWorkspace(record, screenName) {
if (!record || record.placeholder)
return;
switch (compositor) {
@@ -1733,6 +1807,13 @@ Singleton {
NiriService.switchToWorkspace(record.id);
return;
case "hyprland":
if (record.special === true) {
// togglespecialworkspace acts on the focused monitor, so a pill on another monitor's bar focuses that monitor first
if (screenName && screenName !== Hyprland.focusedMonitor?.name)
HyprlandService.focusMonitor(screenName);
HyprlandService.toggleSpecial(record.name === "special" ? "" : record.name);
return;
}
HyprlandService.focusWorkspace(record.id > 0 ? record.id : "name:" + (record.name ?? ""));
return;
case "mango":
+8
View File
@@ -658,6 +658,14 @@ hl.layer_rule({
}
}
function focusMonitor(monitor) {
if (luaConfigActive) {
Hyprland.dispatch(`hl.dsp.focus({ monitor = ${luaString(monitor)} })`);
} else {
Hyprland.dispatch(`focusmonitor ${monitor}`);
}
}
function toggleSpecial(specialName) {
if (luaConfigActive) {
Hyprland.dispatch(`hl.dsp.workspace.toggle_special(${luaString(specialName)})`);
+40 -1
View File
@@ -28,7 +28,7 @@ function hyprland(scenario) {
const workspace = id => workspaces.find(ws => ws.id === id) ?? null;
monitors.forEach((m, i) => m.activeWorkspace = workspace(s.monitors[i].activeWorkspaceId));
const toplevels = s.toplevels.map(tl => ({ workspace: workspace(tl.workspace), wayland: tl.wayland ? { address: tl.address } : null }));
return { workspaces, monitors, focusedWorkspace: workspace(s.focusedWorkspaceId), toplevels, windows: toplevels.filter(tl => tl.wayland).map(tl => tl.wayland) };
return { workspaces, monitors, focusedWorkspace: workspace(s.focusedWorkspaceId), toplevels, windows: toplevels.filter(tl => tl.wayland).map(tl => tl.wayland), visibleSpecials: {} };
}
function i3(scenario) {
@@ -186,6 +186,45 @@ test("mango padding shows the first tags as real targets and caps at the tag cou
assert.deepEqual(pick(model.mangoWorkspacesForScreen(dp1, "DP-1", true, 2), "id"), [0, 1, 2, 3, 4]);
});
test("hyprland special workspaces are opt-in, listed last on their monitor and active while overlaid", () => {
const raw = hyprland("twoScreens");
assert.deepEqual(pick(model.hyprlandWorkspacesForScreen(raw, "DP-1", false, false, 0, false), "id"), [1, 3]);
const shown = model.hyprlandWorkspacesForScreen(raw, "DP-1", false, false, 0, true);
assert.deepEqual(pick(shown, "name"), ["1", "3", "scratch", "special"]);
assert.deepEqual(plain(shown[2]), { id: -98, idx: null, name: "scratch", output: "DP-1", active: false, placeholder: false, urgent: false, special: true });
assert.deepEqual(plain(shown[3]), { id: null, idx: null, name: "special", output: "DP-1", active: false, placeholder: false, urgent: false, special: true });
assert.deepEqual(pick(model.hyprlandWorkspacesForScreen(raw, "HDMI-A-1", false, false, 0, true), "name"), ["2", "4", "chat", "special"]);
assert.deepEqual(pick(model.hyprlandWorkspacesForScreen(raw, "HDMI-A-1", true, false, 0, true), "name"), ["1", "2", "3", "4", "chat", "scratch", "special"]);
assert.deepEqual(pick(model.hyprlandWorkspacesForScreen(raw, "DP-1", false, true, 0, true), "name"), ["1", "3", "special"]);
raw.visibleSpecials = { "DP-1": "special:scratch" };
assert.deepEqual(pick(model.hyprlandWorkspacesForScreen(raw, "DP-1", false, true, 0, true), "active"), [false, true, true, false]);
raw.visibleSpecials = { "HDMI-A-1": "special:special" };
assert.deepEqual(pick(model.hyprlandWorkspacesForScreen(raw, "HDMI-A-1", false, false, 0, true), "active"), [true, false, false, true]);
assert.equal(model.hyprlandWorkspacesForScreen(raw, "DP-1", true, false, 0, true).at(-1).active, true);
assert.deepEqual(plain(model.hyprlandActiveWorkspace(raw, "HDMI-A-1")), { id: "special:special", idx: null, name: "special", output: "HDMI-A-1", active: true, placeholder: false, special: true });
assert.deepEqual(pick([model.hyprlandActiveWorkspace(raw, "DP-1")], "id"), [3]);
raw.visibleSpecials = { "DP-1": "special:scratch" };
assert.deepEqual(plain(model.hyprlandActiveWorkspace(raw, "DP-1")), { id: -98, idx: null, name: "scratch", output: "DP-1", active: true, placeholder: false, special: true });
});
test("hyprland specials without ids match windows by name", () => {
const raw = hyprland("twoScreens");
const scratch = raw.workspaces.find(ws => ws.name === "special:scratch");
scratch.id = 0;
raw.workspaces.push({ id: 0, name: "special:special", monitor: raw.monitors[0], lastIpcObject: { monitor: "DP-1" }, active: false, urgent: false });
const win = { address: "0xd" };
raw.toplevels.push({ workspace: scratch, wayland: win });
raw.windows.push(win);
const shown = model.hyprlandWorkspacesForScreen(raw, "DP-1", false, true, 0, true);
assert.deepEqual(pick(shown, "name"), ["1", "3", "scratch", "special"]);
assert.equal(model.hyprlandWorkspaceOccupied(raw.toplevels, shown[2]), true);
assert.equal(model.hyprlandWorkspaceOccupied(raw.toplevels, shown[3]), false);
assert.deepEqual(pick(model.hyprlandWindowsOnWorkspace(raw.windows, shown[2], raw.toplevels), "address"), ["0xd"]);
assert.deepEqual(model.hyprlandWindowsOnWorkspace(raw.windows, shown[3], raw.toplevels), []);
raw.visibleSpecials = { "DP-1": "special:scratch" };
assert.equal(model.hyprlandActiveWorkspace(raw, "DP-1").id, "special:scratch");
});
test("hyprland scroll targets use ipc monitors, numbered ids and ignore follow focus for current", () => {
const raw = hyprland("twoScreens");
assert.deepEqual(pick(model.hyprlandScrollWorkspaces(raw, "HDMI-A-1", false), "id"), [2, 4]);
@@ -8337,14 +8337,6 @@
"runtimeType": "barWidgetOption",
"optionFile": "SystemTrayOptions.qml"
},
{
"section": "barWidgetOption:WorkspaceSwitcherOptions.qml:8",
"label": "Icons",
"tabIndex": 22,
"keywords": [],
"runtimeType": "barWidgetOption",
"optionFile": "WorkspaceSwitcherOptions.qml"
},
{
"section": "barWidgetOption:WorkspaceSwitcherOptions.qml:2",
"label": "Index numbers",
@@ -8625,6 +8617,14 @@
"runtimeType": "barWidgetOption",
"optionFile": "BatteryOptions.qml"
},
{
"section": "barWidgetOption:WorkspaceSwitcherOptions.qml:8",
"label": "Show scratchpads",
"tabIndex": 22,
"keywords": [],
"runtimeType": "barWidgetOption",
"optionFile": "WorkspaceSwitcherOptions.qml"
},
{
"section": "barWidgetOption:SystemMonitorOptions.qml:2",
"label": "Show swap",