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:
@@ -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.
|
||||
@@ -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"] }
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user