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