diff --git a/niri-active-workspace/README.md b/niri-active-workspace/README.md new file mode 100644 index 00000000..162e09f8 --- /dev/null +++ b/niri-active-workspace/README.md @@ -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. diff --git a/niri-active-workspace/plugin.toml b/niri-active-workspace/plugin.toml new file mode 100644 index 00000000..b63e316f --- /dev/null +++ b/niri-active-workspace/plugin.toml @@ -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" diff --git a/niri-active-workspace/thumbnail.webp b/niri-active-workspace/thumbnail.webp new file mode 100644 index 00000000..73176590 Binary files /dev/null and b/niri-active-workspace/thumbnail.webp differ diff --git a/niri-active-workspace/translations/en.json b/niri-active-workspace/translations/en.json new file mode 100644 index 00000000..3b504837 --- /dev/null +++ b/niri-active-workspace/translations/en.json @@ -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." + } + } +} diff --git a/niri-active-workspace/widget.luau b/niri-active-workspace/widget.luau new file mode 100644 index 00000000..e02d713b --- /dev/null +++ b/niri-active-workspace/widget.luau @@ -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