Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions niri-active-workspace/README.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 42 additions & 0 deletions niri-active-workspace/plugin.toml
Original file line number Diff line number Diff line change
@@ -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 added niri-active-workspace/thumbnail.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions niri-active-workspace/translations/en.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
221 changes: 221 additions & 0 deletions niri-active-workspace/widget.luau
Original file line number Diff line number Diff line change
@@ -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
Loading