feat: Add Hyprland special workspaces plugin (#116)

* feat: Add Hyprland special workspaces plugin

* fix: Nest translation keys under settings object
This commit is contained in:
Jetsadakorn Maliwan
2026-07-26 23:06:15 -04:00
committed by GitHub
parent 4d1d17f39a
commit 0d4f1277c4
6 changed files with 466 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
# Special Workspaces
Displays populated and currently active Hyprland special workspaces as compact
chips in the Noctalia bar.
## Plugin
| Field | Value |
| --- | --- |
| ID | `jamesfeeder/special-workspaces` |
| Entries | Bar widget: `special-workspaces`; service: `workspace-state` |
## Requirements
- Noctalia v5 with plugin API 3 or newer.
- Hyprland, including its `hyprctl` utility.
- Install `socat` on `PATH`.
## Usage
Enable `jamesfeeder/special-workspaces` in **Settings → Plugins**, then add
**Special Workspaces** to the bar from **Settings → Bar**.
The widget shows special workspaces in alphabetical order. Active workspaces
remain visible when empty. Inactive workspaces appear only while populated and
can be hidden with `hide_inactive`.
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `max_label_chars` | `int` | `0` | Maximum Unicode characters shown from each workspace name. `0` shows the full name. |
| `hide_inactive` | `bool` | `false` | Hide populated workspaces unless they are visible on a monitor. |
| `capsule_radius` | `int` | `8` | Corner radius in logical pixels. Negative values are treated as `0`. |
| `capsule_padding` | `int` | `5` | Space before and after the label along the bar axis, in logical pixels. Negative values are treated as `0`. |
| `capsule_min_width` | `int` | `35` | Minimum capsule length along the bar axis, in logical pixels. Negative values are treated as `0`. |
| `active_style` | `select` | `"fill"` | Active capsule style: `"fill"` or `"ghost"`. |
| `inactive_style` | `select` | `"fill"` | Inactive capsule style: `"fill"` or `"ghost"`. Hidden when `hide_inactive` is enabled. |
## Notes
- Active means visible on any monitor, not focused.
- Fill style uses `primary` colors for active workspaces and `secondary` colors
for inactive workspaces. Ghost style uses a transparent fill with `primary`
or `on_surface` text.
- On vertical bars, capsules grow vertically and display one Unicode character
per line from top to bottom.
- The service snapshots `hyprctl -j clients` and `hyprctl -j monitors`, then
listens to Hyprland's `.socket2.sock` through `socat`.
- If the event socket disconnects, the service waits for it to return,
refreshes its snapshot, and reconnects. It retains the last valid state while
`hyprctl` is unavailable and retries failed snapshots up to twice.
+78
View File
@@ -0,0 +1,78 @@
id = "jamesfeeder/special-workspaces"
name = "Special Workspaces"
version = "1.4.0"
plugin_api = 3
author = "jamesfeeder"
license = "MIT"
icon = "stack-2"
description = "Displays populated and currently active Hyprland special workspaces"
tags = ["hyprland", "indicator", "bar"]
dependencies = ["socat"]
[[service]]
id = "workspace-state"
entry = "service.luau"
[[widget]]
id = "special-workspaces"
entry = "widget.luau"
[[widget.setting]]
key = "max_label_chars"
type = "int"
label_key = "settings.max_label_chars.label"
description_key = "settings.max_label_chars.description"
default = 0
min = 0
max = 64
[[widget.setting]]
key = "hide_inactive"
type = "bool"
label_key = "settings.hide_inactive.label"
description_key = "settings.hide_inactive.description"
default = false
[[widget.setting]]
key = "capsule_radius"
type = "int"
label_key = "settings.capsule_radius.label"
description_key = "settings.capsule_radius.description"
default = 8
[[widget.setting]]
key = "capsule_padding"
type = "int"
label_key = "settings.capsule_padding.label"
description_key = "settings.capsule_padding.description"
default = 5
[[widget.setting]]
key = "capsule_min_width"
type = "int"
label_key = "settings.capsule_min_width.label"
description_key = "settings.capsule_min_width.description"
default = 35
[[widget.setting]]
key = "active_style"
type = "select"
label_key = "settings.active_style.label"
description_key = "settings.active_style.description"
default = "fill"
options = [
{ value = "fill", label_key = "settings.style.fill" },
{ value = "ghost", label_key = "settings.style.ghost" },
]
[[widget.setting]]
key = "inactive_style"
type = "select"
label_key = "settings.inactive_style.label"
description_key = "settings.inactive_style.description"
default = "fill"
options = [
{ value = "fill", label_key = "settings.style.fill" },
{ value = "ghost", label_key = "settings.style.ghost" },
]
visible_when = { key = "hide_inactive", values = ["false"] }
+194
View File
@@ -0,0 +1,194 @@
-- Publishes populated or active Hyprland special workspaces to the plugin state
-- channel.
-- Hyprland's event socket triggers snapshots after relevant changes.
local STATE_KEY = "special_workspaces"
local SEPARATOR = "__NOCTALIA_SPECIAL_WORKSPACES_MONITORS__"
local STREAM_REFRESH_MARKER = "__NOCTALIA_SPECIAL_WORKSPACES_STREAM_REFRESH__"
local SNAPSHOT_ATTEMPTS = 3
local loggedSnapshotError = false
local refreshInFlight = false
local refreshPending = false
local function logSnapshotError(message)
if not loggedSnapshotError then
noctalia.log("Special workspaces: " .. message .. "; retaining last state")
loggedSnapshotError = true
end
end
local function shellQuote(value)
return "'" .. string.gsub(value, "'", "'\"'\"'") .. "'"
end
local function specialName(value)
if type(value) ~= "string" then
return nil
end
local name = string.match(value, "^special:(.+)$")
if name and name ~= "" then
return name
end
return nil
end
local function snapshotFromJson(clientsJson, monitorsJson)
local clients, clientErr = noctalia.json.decode(clientsJson)
local monitors, monitorErr = noctalia.json.decode(monitorsJson)
if type(clients) ~= "table" or type(monitors) ~= "table" then
return nil, "invalid Hyprland JSON" .. (clientErr and (": " .. clientErr) or (monitorErr and (": " .. monitorErr) or ""))
end
local workspaces = {}
for _, client in ipairs(clients) do
local workspace = client.workspace
local name = workspace and specialName(workspace.name)
if name then
local item = workspaces[name]
if not item then
item = { name = name, windowCount = 0, active = false }
workspaces[name] = item
end
item.windowCount = item.windowCount + 1
end
end
for _, monitor in ipairs(monitors) do
local special = monitor.specialWorkspace
local name = special and specialName(special.name)
if name then
local item = workspaces[name]
if not item then
item = { name = name, windowCount = 0, active = false }
workspaces[name] = item
end
item.active = true
end
end
local result = {}
for _, item in pairs(workspaces) do
table.insert(result, item)
end
table.sort(result, function(a, b) return a.name < b.name end)
return result
end
local function publishSnapshot(clientsJson, monitorsJson)
local state, err = snapshotFromJson(clientsJson, monitorsJson)
if not state then
-- Keep the last valid state during temporary compositor command failures.
logSnapshotError("snapshot failed (" .. err .. ")")
return false
end
loggedSnapshotError = false
noctalia.state.set(STATE_KEY, state)
return true
end
local function refresh()
if refreshInFlight then
refreshPending = true
return
end
refreshInFlight = true
local command = "attempt=1; while [ \"$attempt\" -le " .. SNAPSHOT_ATTEMPTS
.. " ]; do clients=$(hyprctl -j clients) && monitors=$(hyprctl -j monitors)"
.. " && { printf '%s\\n" .. SEPARATOR .. "\\n%s\\n' \"$clients\" \"$monitors\"; exit 0; };"
.. " attempt=$((attempt + 1)); [ \"$attempt\" -le " .. SNAPSHOT_ATTEMPTS
.. " ] && sleep 1; done; exit 1"
local started = noctalia.runAsync(command, function(result)
if result.exitCode ~= 0 then
logSnapshotError("hyprctl failed")
else
local markerStart, markerEnd = string.find(result.stdout, "\n" .. SEPARATOR .. "\n", 1, true)
if not markerStart then
logSnapshotError("incomplete hyprctl snapshot")
else
publishSnapshot(string.sub(result.stdout, 1, markerStart - 1), string.sub(result.stdout, markerEnd + 1))
end
end
refreshInFlight = false
if refreshPending then
refreshPending = false
refresh()
end
end)
if not started then
refreshInFlight = false
logSnapshotError("could not start hyprctl snapshot")
end
end
local refreshEvents = {
activespecial = true,
activespecialv2 = true,
openwindow = true,
closewindow = true,
movewindow = true,
movewindowv2 = true,
workspace = true,
workspacev2 = true,
focusedmon = true,
focusedmonv2 = true,
createworkspace = true,
createworkspacev2 = true,
destroyworkspace = true,
destroyworkspacev2 = true,
moveworkspace = true,
moveworkspacev2 = true,
renameworkspace = true,
monitoradded = true,
monitoraddedv2 = true,
monitorremoved = true,
monitorremovedv2 = true,
}
local function startEventStream()
if not noctalia.commandExists("socat") then
noctalia.log("Special workspaces: socat is unavailable; event updates disabled")
return
end
local runtimeDir = noctalia.getenv("XDG_RUNTIME_DIR")
local signature = noctalia.getenv("HYPRLAND_INSTANCE_SIGNATURE")
if not runtimeDir or not signature then
noctalia.log("Special workspaces: Hyprland environment is unavailable; event updates disabled")
return
end
local socket = runtimeDir .. "/hypr/" .. signature .. "/.socket2.sock"
-- Keep a single helper alive across temporary socket failures. Noctalia stops
-- the complete stream command automatically when this service exits.
local command = "while true; do while [ ! -S " .. shellQuote(socket)
.. " ]; do sleep 5; done; printf '" .. STREAM_REFRESH_MARKER
.. "\\n'; socat -u UNIX-CONNECT:" .. shellQuote(socket)
.. " - 2>&1; sleep 5; done"
local started = noctalia.runStream(command, function(line)
if line == STREAM_REFRESH_MARKER then
refresh()
return
end
local event = string.match(line, "^([%a%d_]+)>>")
if event then
if refreshEvents[event] then
refresh()
end
end
end)
if not started then
noctalia.log("Special workspaces: could not start socat event stream")
end
end
refresh()
startEventStream()
function onOutputsChanged()
refresh()
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+36
View File
@@ -0,0 +1,36 @@
{
"settings": {
"max_label_chars": {
"label": "Maximum label characters",
"description": "Maximum characters shown from each workspace name. Use 0 to show the full name."
},
"hide_inactive": {
"label": "Hide inactive workspaces",
"description": "Hide populated special workspaces unless they are currently visible on a monitor."
},
"capsule_radius": {
"label": "Capsule radius",
"description": "Corner radius of each capsule in logical pixels. Values below 0 are treated as 0."
},
"capsule_padding": {
"label": "Capsule content padding",
"description": "Space before and after the label along the bar axis, in logical pixels. Values below 0 are treated as 0."
},
"capsule_min_width": {
"label": "Capsule minimum length",
"description": "Minimum capsule length along the bar axis: width on horizontal bars, height on vertical bars. Values below 0 are treated as 0."
},
"active_style": {
"label": "Active workspace style",
"description": "Show active workspaces as primary-filled capsules or transparent labels with primary text."
},
"inactive_style": {
"label": "Inactive workspace style",
"description": "Show inactive workspaces as secondary-filled capsules or transparent labels with surface text."
},
"style": {
"fill": "Fill",
"ghost": "Ghost"
}
}
}
+106
View File
@@ -0,0 +1,106 @@
local STATE_KEY = "special_workspaces"
local PILL_HEIGHT = 16
local PILL_GAP = 4
local state = noctalia.state.get(STATE_KEY) or {}
local maxLabelChars = tonumber(noctalia.getConfig("max_label_chars")) or 0
local hideInactive = noctalia.getConfig("hide_inactive") == true
local capsuleRadius = math.max(0, tonumber(noctalia.getConfig("capsule_radius")) or 8)
local capsulePadding = math.max(0, tonumber(noctalia.getConfig("capsule_padding")) or 5)
local capsuleMinWidth = math.max(0, tonumber(noctalia.getConfig("capsule_min_width")) or 35)
local activeStyle = noctalia.getConfig("active_style") or "fill"
local inactiveStyle = noctalia.getConfig("inactive_style") or "fill"
local renderedVertical = nil
local function displayName(name)
if maxLabelChars <= 0 then
return name
end
local nextOffset = utf8.offset(name, maxLabelChars + 1)
if nextOffset then
return string.sub(name, 1, nextOffset - 1)
end
return name
end
local function verticalName(name)
local characters = {}
for _, codepoint in utf8.codes(name) do
table.insert(characters, utf8.char(codepoint))
end
return table.concat(characters, "\n")
end
local function render(vertical)
renderedVertical = vertical
local container = vertical and ui.column or ui.row
local capsule = vertical and ui.column or ui.row
local chips = {}
for _, workspace in ipairs(state) do
if workspace.active or not hideInactive then
local name = workspace.name
local style = workspace.active and activeStyle or inactiveStyle
local filled = style == "fill"
local fill = filled
and (workspace.active and "primary" or "secondary")
or "#00000000"
local textColor = filled
and (workspace.active and "on_primary" or "on_secondary")
or (workspace.active and "primary" or "on_surface")
local capsuleProps = {
key = name,
align = "center",
justify = "center",
fill = fill,
radius = capsuleRadius,
}
if vertical then
capsuleProps.width = PILL_HEIGHT
capsuleProps.minHeight = capsuleMinWidth
capsuleProps.paddingV = capsulePadding
else
capsuleProps.height = PILL_HEIGHT
capsuleProps.minWidth = capsuleMinWidth
capsuleProps.paddingH = capsulePadding
end
local label = displayName(name)
table.insert(chips, capsule(capsuleProps, {
ui.label({
text = vertical and verticalName(label) or label,
fontSize = 11,
color = textColor,
textAlign = "center",
}),
}))
end
end
barWidget.render(container({ gap = PILL_GAP, align = "center" }, chips))
end
function update()
local vertical = barWidget.isVertical()
if vertical ~= renderedVertical then
render(vertical)
end
end
noctalia.state.watch(STATE_KEY, function(value)
state = type(value) == "table" and value or {}
render(renderedVertical)
end)
function onConfigChanged()
maxLabelChars = tonumber(noctalia.getConfig("max_label_chars")) or 0
hideInactive = noctalia.getConfig("hide_inactive") == true
capsuleRadius = math.max(0, tonumber(noctalia.getConfig("capsule_radius")) or 8)
capsulePadding = math.max(0, tonumber(noctalia.getConfig("capsule_padding")) or 5)
capsuleMinWidth = math.max(0, tonumber(noctalia.getConfig("capsule_min_width")) or 35)
activeStyle = noctalia.getConfig("active_style") or "fill"
inactiveStyle = noctalia.getConfig("inactive_style") or "fill"
render(renderedVertical)
end
render(barWidget.isVertical())