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,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