diff --git a/special-workspaces/README.md b/special-workspaces/README.md new file mode 100644 index 0000000..cb092f5 --- /dev/null +++ b/special-workspaces/README.md @@ -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. diff --git a/special-workspaces/plugin.toml b/special-workspaces/plugin.toml new file mode 100644 index 0000000..de960a3 --- /dev/null +++ b/special-workspaces/plugin.toml @@ -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"] } diff --git a/special-workspaces/service.luau b/special-workspaces/service.luau new file mode 100644 index 0000000..7f03117 --- /dev/null +++ b/special-workspaces/service.luau @@ -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 diff --git a/special-workspaces/thumbnail.webp b/special-workspaces/thumbnail.webp new file mode 100644 index 0000000..718df6a Binary files /dev/null and b/special-workspaces/thumbnail.webp differ diff --git a/special-workspaces/translations/en.json b/special-workspaces/translations/en.json new file mode 100644 index 0000000..791f724 --- /dev/null +++ b/special-workspaces/translations/en.json @@ -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" + } + } +} diff --git a/special-workspaces/widget.luau b/special-workspaces/widget.luau new file mode 100644 index 0000000..a3b0632 --- /dev/null +++ b/special-workspaces/widget.luau @@ -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())