Add salemsayed/niri-active-workspace (#227)
A bar widget showing only the focused niri workspace instead of a pill listing every workspace. Reads niri IPC (snapshot plus event-stream) since the plugin API exposes no compositor state.
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
# Niri Active Workspace
|
||||||
|
|
||||||
|
A bar widget that shows **only the workspace you are on**, instead of a pill
|
||||||
|
listing every workspace.
|
||||||
|
|
||||||
|
Noctalia's built-in workspaces widget always renders one slot per workspace.
|
||||||
|
With named workspaces, or under niri — which keeps a trailing empty workspace at
|
||||||
|
all times — that pill is wider than the information in it, and `inactive_pill_size`
|
||||||
|
only shrinks inactive slots to a floor of `0.25`, it cannot hide them. This
|
||||||
|
widget renders a single label and puts the full list in the tooltip.
|
||||||
|
|
||||||
|
## Plugin
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | `salemsayed/niri-active-workspace` |
|
||||||
|
| Entries | Bar widget: `active-workspace` |
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- `niri`. The plugin API exposes no compositor state, so all workspace data
|
||||||
|
comes from niri's own IPC (`niri msg`). The widget is niri-only and will show
|
||||||
|
a placeholder under any other compositor.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Add it from **Settings → Bar**, choose a section, and pick **Niri Active
|
||||||
|
Workspace** from the widget list. It replaces the built-in `workspaces` widget;
|
||||||
|
remove that one if you do not want both.
|
||||||
|
|
||||||
|
The widget shows the workspace name, falling back to its index when the
|
||||||
|
workspace is unnamed.
|
||||||
|
|
||||||
|
| Gesture | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| Left click | Toggle the niri overview |
|
||||||
|
| Scroll up / down | Focus the workspace above / below |
|
||||||
|
|
||||||
|
Hover for a tooltip listing every workspace on the same monitor, with `●`
|
||||||
|
marking the focused one and `○` the rest.
|
||||||
|
|
||||||
|
### Multiple monitors
|
||||||
|
|
||||||
|
Noctalia renders a bar per output. Each widget instance resolves its own
|
||||||
|
connector and tracks the workspace that is active **on that output**, so the bar
|
||||||
|
on each monitor shows that monitor's workspace rather than whichever one holds
|
||||||
|
keyboard focus. If the host cannot report the output, the widget falls back to
|
||||||
|
the globally focused workspace.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
| Setting | Type | Default | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `label_mode` | `select` | `name_or_index` | What to show. `name_or_index` uses the workspace name and falls back to its index; `name` shows the name only; `index` shows the index only; `both` shows `index:name`. |
|
||||||
|
| `show_position` | `bool` | `false` | Append the workspace's position among that monitor's workspaces, for example `comms 2/5`. |
|
||||||
|
| `glyph` | `glyph` | `layout-grid` | Icon shown before the label. |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
**Processes.** The widget spawns `niri msg` and nothing else:
|
||||||
|
|
||||||
|
- `niri msg --json workspaces` — once at load, and again on a 60-second tick as
|
||||||
|
a self-heal in case the event stream dies.
|
||||||
|
- `niri msg --json event-stream` — a single long-lived subscription, the source
|
||||||
|
of live updates. `WorkspacesChanged` and `WorkspaceActivated` are handled; all
|
||||||
|
other events are ignored.
|
||||||
|
- `niri msg action toggle-overview`, `focus-workspace-down`,
|
||||||
|
`focus-workspace-up` — only in response to a click or scroll.
|
||||||
|
|
||||||
|
**No network access. No filesystem reads or writes.** The widget keeps its state
|
||||||
|
in memory and writes nothing to disk.
|
||||||
|
|
||||||
|
**Workspace identity.** niri marks one workspace `is_active` per output and
|
||||||
|
exactly one `is_focused` globally. On a single monitor these are the same
|
||||||
|
workspace, so the two code paths agree.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
id = "salemsayed/niri-active-workspace"
|
||||||
|
name = "Niri Active Workspace"
|
||||||
|
version = "1.1.0"
|
||||||
|
plugin_api = 3
|
||||||
|
author = "salemsayed"
|
||||||
|
license = "MIT"
|
||||||
|
dependencies = ["niri"]
|
||||||
|
icon = "layout-grid"
|
||||||
|
deprecated = false
|
||||||
|
description = "Shows only the focused niri workspace in the bar."
|
||||||
|
tags = ["bar", "indicator", "niri"]
|
||||||
|
|
||||||
|
[[widget]]
|
||||||
|
id = "active-workspace"
|
||||||
|
entry = "widget.luau"
|
||||||
|
|
||||||
|
[[widget.setting]]
|
||||||
|
key = "label_mode"
|
||||||
|
type = "select"
|
||||||
|
label_key = "settings.label_mode.label"
|
||||||
|
description_key = "settings.label_mode.description"
|
||||||
|
default = "name_or_index"
|
||||||
|
options = [
|
||||||
|
{ value = "name_or_index", label_key = "settings.label_mode.name_or_index" },
|
||||||
|
{ value = "name", label_key = "settings.label_mode.name" },
|
||||||
|
{ value = "index", label_key = "settings.label_mode.index" },
|
||||||
|
{ value = "both", label_key = "settings.label_mode.both" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[widget.setting]]
|
||||||
|
key = "show_position"
|
||||||
|
type = "bool"
|
||||||
|
label_key = "settings.show_position.label"
|
||||||
|
description_key = "settings.show_position.description"
|
||||||
|
default = false
|
||||||
|
|
||||||
|
[[widget.setting]]
|
||||||
|
key = "glyph"
|
||||||
|
type = "glyph"
|
||||||
|
label_key = "settings.glyph.label"
|
||||||
|
description_key = "settings.glyph.description"
|
||||||
|
default = "layout-grid"
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"plugin_name": "Niri Active Workspace",
|
||||||
|
"plugin_description": "Shows only the focused niri workspace in the bar.",
|
||||||
|
"settings": {
|
||||||
|
"label_mode": {
|
||||||
|
"label": "Label",
|
||||||
|
"description": "What to show for the focused workspace.",
|
||||||
|
"name_or_index": "Name, or index when unnamed",
|
||||||
|
"name": "Name only",
|
||||||
|
"index": "Index only",
|
||||||
|
"both": "Index and name"
|
||||||
|
},
|
||||||
|
"show_position": {
|
||||||
|
"label": "Show position",
|
||||||
|
"description": "Append the focused workspace's position, e.g. 3/5."
|
||||||
|
},
|
||||||
|
"glyph": {
|
||||||
|
"label": "Icon",
|
||||||
|
"description": "Icon shown before the label."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
--!nonstrict
|
||||||
|
-- niri-active-workspace — a bar widget that shows only the focused niri
|
||||||
|
-- workspace, instead of a pill containing every workspace.
|
||||||
|
--
|
||||||
|
-- The plugin API exposes no compositor state, so workspace data comes from
|
||||||
|
-- niri's own IPC:
|
||||||
|
-- * one `niri msg --json workspaces` snapshot at load, and again on the slow
|
||||||
|
-- update tick as a self-heal in case the stream dies
|
||||||
|
-- * `niri msg --json event-stream` for live updates
|
||||||
|
--
|
||||||
|
-- Multi-monitor: niri marks one workspace `is_active` per output and exactly
|
||||||
|
-- one `is_focused` globally. Noctalia renders a bar per output, so each widget
|
||||||
|
-- instance reports its own connector via barWidget.outputName() and tracks the
|
||||||
|
-- active workspace on that output. With a single output the two are the same
|
||||||
|
-- workspace, so behaviour there is unchanged.
|
||||||
|
--
|
||||||
|
-- Left click → toggle the niri overview
|
||||||
|
-- Scroll → focus the workspace below / above
|
||||||
|
|
||||||
|
local LABEL_MODE: string = noctalia.getConfig("label_mode") or "name_or_index"
|
||||||
|
local SHOW_POSITION: boolean = noctalia.getConfig("show_position") == true
|
||||||
|
local GLYPH: string = noctalia.getConfig("glyph") or ""
|
||||||
|
|
||||||
|
-- Resync cadence. The event stream does the real work; this only covers the
|
||||||
|
-- case where it dies silently.
|
||||||
|
local RESYNC_MS: number = 60000
|
||||||
|
|
||||||
|
local allWorkspaces: { any } = {}
|
||||||
|
|
||||||
|
-- ── Output scoping ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- nil when the host cannot say (older API, or before the bar is placed), in
|
||||||
|
-- which case fall back to the globally focused workspace.
|
||||||
|
local function outputName(): string?
|
||||||
|
if barWidget.outputName == nil then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local ok, name = pcall(barWidget.outputName)
|
||||||
|
if ok and name ~= nil and name ~= "" then
|
||||||
|
return name
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function visibleWorkspaces(): { any }
|
||||||
|
local connector = outputName()
|
||||||
|
if connector == nil then
|
||||||
|
return allWorkspaces
|
||||||
|
end
|
||||||
|
local scoped = {}
|
||||||
|
for _, ws in ipairs(allWorkspaces) do
|
||||||
|
if ws.output == connector then
|
||||||
|
table.insert(scoped, ws)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- An output we have no workspaces for should not blank the widget.
|
||||||
|
if #scoped == 0 then
|
||||||
|
return allWorkspaces
|
||||||
|
end
|
||||||
|
return scoped
|
||||||
|
end
|
||||||
|
|
||||||
|
local function selectFocused(scoped: { any }): any
|
||||||
|
local connector = outputName()
|
||||||
|
for _, ws in ipairs(scoped) do
|
||||||
|
-- Per-output active workspace when we know which output we are on,
|
||||||
|
-- otherwise the single globally focused one.
|
||||||
|
if connector ~= nil and ws.is_active then
|
||||||
|
return ws
|
||||||
|
elseif connector == nil and ws.is_focused then
|
||||||
|
return ws
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- Fall back to global focus if the output had no active workspace.
|
||||||
|
for _, ws in ipairs(scoped) do
|
||||||
|
if ws.is_focused then
|
||||||
|
return ws
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ── Rendering ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local function labelFor(ws: any): string
|
||||||
|
if ws == nil then
|
||||||
|
return "—"
|
||||||
|
end
|
||||||
|
local idx: string = tostring(ws.idx or "?")
|
||||||
|
local name: string? = ws.name
|
||||||
|
|
||||||
|
if LABEL_MODE == "index" then
|
||||||
|
return idx
|
||||||
|
elseif LABEL_MODE == "name" then
|
||||||
|
return name or idx
|
||||||
|
elseif LABEL_MODE == "both" then
|
||||||
|
if name ~= nil and name ~= "" then
|
||||||
|
return `{idx}:{name}`
|
||||||
|
end
|
||||||
|
return idx
|
||||||
|
end
|
||||||
|
-- "name_or_index" (default)
|
||||||
|
if name ~= nil and name ~= "" then
|
||||||
|
return name
|
||||||
|
end
|
||||||
|
return idx
|
||||||
|
end
|
||||||
|
|
||||||
|
local function render()
|
||||||
|
if GLYPH ~= "" then
|
||||||
|
barWidget.setGlyph(GLYPH)
|
||||||
|
end
|
||||||
|
|
||||||
|
local scoped = visibleWorkspaces()
|
||||||
|
local focused = selectFocused(scoped)
|
||||||
|
|
||||||
|
local text: string = labelFor(focused)
|
||||||
|
if SHOW_POSITION and focused ~= nil and #scoped > 0 then
|
||||||
|
text = `{text} {focused.idx}/{#scoped}`
|
||||||
|
end
|
||||||
|
barWidget.setText(text)
|
||||||
|
|
||||||
|
-- Tooltip keeps the full list for this output reachable, since the bar no
|
||||||
|
-- longer shows it.
|
||||||
|
local rows = {}
|
||||||
|
for _, ws in ipairs(scoped) do
|
||||||
|
local marker: string = (focused ~= nil and ws.id == focused.id) and "●" or "○"
|
||||||
|
table.insert(rows, {
|
||||||
|
key = `{marker} {tostring(ws.idx)}`,
|
||||||
|
value = (ws.name ~= nil and ws.name ~= "") and ws.name or "(unnamed)",
|
||||||
|
})
|
||||||
|
end
|
||||||
|
if #rows > 0 then
|
||||||
|
barWidget.setTooltip(rows)
|
||||||
|
else
|
||||||
|
barWidget.setTooltip("No niri workspaces")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ── State ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local function applyWorkspaces(list: { any })
|
||||||
|
allWorkspaces = {}
|
||||||
|
for _, ws in ipairs(list) do
|
||||||
|
table.insert(allWorkspaces, ws)
|
||||||
|
end
|
||||||
|
table.sort(allWorkspaces, function(a, b)
|
||||||
|
return (a.idx or 0) < (b.idx or 0)
|
||||||
|
end)
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- WorkspaceActivated carries only { id, focused }, so patch the cached list
|
||||||
|
-- rather than re-querying niri on every switch. Activation is per-output: it
|
||||||
|
-- clears is_active on the activated workspace's own output only, while
|
||||||
|
-- is_focused is global.
|
||||||
|
local function applyActivated(id: any, isFocused: boolean)
|
||||||
|
local activated: any = nil
|
||||||
|
for _, ws in ipairs(allWorkspaces) do
|
||||||
|
if ws.id == id then
|
||||||
|
activated = ws
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if activated == nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, ws in ipairs(allWorkspaces) do
|
||||||
|
if ws.output == activated.output then
|
||||||
|
ws.is_active = ws.id == id
|
||||||
|
end
|
||||||
|
if isFocused then
|
||||||
|
ws.is_focused = ws.id == id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function snapshot()
|
||||||
|
noctalia.runAsync("niri msg --json workspaces", function(res)
|
||||||
|
if res.exitCode ~= 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local data = noctalia.json.decode(res.stdout)
|
||||||
|
if type(data) == "table" then
|
||||||
|
applyWorkspaces(data)
|
||||||
|
end
|
||||||
|
end, 3000)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ── Wiring ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
snapshot()
|
||||||
|
|
||||||
|
noctalia.runStream("niri msg --json event-stream", function(line: string)
|
||||||
|
local ev = noctalia.json.decode(line)
|
||||||
|
if type(ev) ~= "table" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if ev.WorkspacesChanged ~= nil then
|
||||||
|
applyWorkspaces(ev.WorkspacesChanged.workspaces or {})
|
||||||
|
elseif ev.WorkspaceActivated ~= nil then
|
||||||
|
local a = ev.WorkspaceActivated
|
||||||
|
applyActivated(a.id, a.focused == true)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
function update()
|
||||||
|
noctalia.setUpdateInterval(RESYNC_MS)
|
||||||
|
snapshot()
|
||||||
|
end
|
||||||
|
|
||||||
|
function onClick()
|
||||||
|
noctalia.runAsync("niri msg action toggle-overview")
|
||||||
|
end
|
||||||
|
|
||||||
|
function onScroll(axis: string, steps: number, startsGesture: boolean)
|
||||||
|
local action: string = (steps > 0) and "focus-workspace-down" or "focus-workspace-up"
|
||||||
|
noctalia.runAsync(`niri msg action {action}`)
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user