aboutsummaryrefslogtreecommitdiffstats
path: root/modules/desktop/eww
diff options
context:
space:
mode:
Diffstat (limited to 'modules/desktop/eww')
-rw-r--r--modules/desktop/eww/activate-windows.py118
-rw-r--r--modules/desktop/eww/bar.yuck16
-rw-r--r--modules/desktop/eww/niri-status.lua254
-rw-r--r--modules/desktop/eww/system-stats.lua826
-rw-r--r--modules/desktop/eww/system-stats.py587
-rw-r--r--modules/desktop/eww/workspaces.py121
-rw-r--r--modules/desktop/eww/yuck.nix54
7 files changed, 1113 insertions, 863 deletions
diff --git a/modules/desktop/eww/activate-windows.py b/modules/desktop/eww/activate-windows.py
deleted file mode 100644
index f07b6a2..0000000
--- a/modules/desktop/eww/activate-windows.py
+++ /dev/null
@@ -1,118 +0,0 @@
-#!/usr/bin/env python3
-"""active-windows: emit icons for windows on the currently active
-niri workspace, driven by niri's event-stream instead of polling."""
-import json
-import subprocess
-import gi
-from gi.repository import Gio, Gtk
-
-gi.require_version("Gtk", "3.0")
-
-icon_theme = Gtk.IconTheme.get_default()
-_icon_cache: dict[str, str] = {}
-
-
-def try_desktop_app_info(desktop_id: str):
- try:
- return Gio.DesktopAppInfo.new(desktop_id)
- except TypeError:
- return None
-
-
-def resolve_icon_path(app_id: str) -> str:
- if not app_id:
- return ""
- if app_id in _icon_cache:
- return _icon_cache[app_id]
-
- app_info = try_desktop_app_info(f"{app_id}.desktop") \
- or try_desktop_app_info(f"{app_id.lower()}.desktop")
-
- if app_info is None:
- for info in Gio.AppInfo.get_all():
- if isinstance(info, Gio.DesktopAppInfo):
- wm_class = info.get_startup_wm_class()
- if wm_class and wm_class.lower() == app_id.lower():
- app_info = info
- break
-
- icon_path = ""
- gicon = app_info.get_icon() if app_info else None
- if gicon is not None:
- lookup = icon_theme.lookup_by_gicon(gicon, 24, Gtk.IconLookupFlags.FORCE_SIZE)
- if lookup:
- icon_path = lookup.get_filename() or ""
- if not icon_path:
- lookup = icon_theme.lookup_icon(app_id, 24, Gtk.IconLookupFlags.FORCE_SIZE)
- if lookup:
- icon_path = lookup.get_filename() or ""
-
- _icon_cache[app_id] = icon_path
- return icon_path
-
-
-def niri_json(*args):
- out = subprocess.run(["niri", "msg", "-j", *args],
- capture_output=True,
- text=True,
- check=True)
- return json.loads(out.stdout)
-
-
-def emit_active_windows():
- workspaces = niri_json("workspaces")
- windows = niri_json("windows")
-
- active_ws = next((w for w in workspaces if w.get("is_active")), None)
- active_id = active_ws["id"] if active_ws else None
-
- on_workspace = [w for w in windows if w.get("workspace_id") == active_id]
-
- def sort_key(w):
- pos = (w.get("layout") or {}).get("pos_in_scrolling_layout")
- if pos is not None:
- return (0, pos[0], pos[1])
- # floating / positionless windows: push to the end, stable by id
- return (1, w["id"], 0)
-
- on_workspace.sort(key=sort_key)
-
- result = [
- {
- "id": w["id"],
- "title": w.get("title", ""),
- "app_id": w.get("app_id") or "",
- "is_focused": w.get("is_focused", False),
- "icon": resolve_icon_path(w.get("app_id") or ""),
- }
- for w in on_workspace
- ]
- print(json.dumps(result), flush=True)
-
-
-RELEVANT_EVENTS = {
- "WindowOpenedOrChanged", "WindowClosed", "WindowFocusChanged",
- "WorkspaceActivated", "WorkspacesChanged", "WindowsChanged",
-}
-
-
-def main():
- emit_active_windows()
- proc = subprocess.Popen(
- ["niri", "msg", "-j", "event-stream"],
- stdout=subprocess.PIPE, text=True, bufsize=1,
- )
- for line in proc.stdout:
- try:
- key = next(iter(json.loads(line)))
- except (json.JSONDecodeError, StopIteration):
- continue
- if key in RELEVANT_EVENTS:
- try:
- emit_active_windows()
- except subprocess.CalledProcessError:
- pass
-
-
-if __name__ == "__main__":
- main()
diff --git a/modules/desktop/eww/bar.yuck b/modules/desktop/eww/bar.yuck
index f7fdf49..2f5046d 100644
--- a/modules/desktop/eww/bar.yuck
+++ b/modules/desktop/eww/bar.yuck
@@ -2,11 +2,10 @@
(defpoll ampm :interval "10s" "date '+%p'")
(defpoll date :interval "60s" "date '+%d/%m'")
(defpoll full_date :interval "60s" "date '+%A, %d %B %Y'")
-(deflisten workspaces :initial "[]" "@@workspaces-script@@")
-(deflisten active-windows :initial "[]" "@@windows-script@@")
+(deflisten niri_status :initial "{\"workspaces\":[],\"active_windows\":[]}" "@@niri-status-script@@")
(deflisten system_stats
:initial "{\"cpu\":{\"percent\":0,\"per_core\":[],\"freq_mhz\":null,\"load_avg\":null,\"tooltip\":\"CPU: 0%\"},\"ram\":{\"percent\":0,\"used\":\"0B\",\"total\":\"0B\",\"available\":\"0B\",\"swap_percent\":0,\"swap_used\":\"0B\",\"swap_total\":\"0B\",\"speed_mhz\":null,\"tooltip\":\"RAM: 0%\"},\"net\":{\"status\":\"down\",\"tooltip\":\"Network Offline\",\"routes\":[]},\"sound\":{\"percent\":0,\"mute\":true,\"sink\":null,\"tooltip\":\"Volume: unknown\"}}"
- "@@systemstats-script@@")
+ "@@system-stats-script@@")
(defwindow bar
:monitor 0
@@ -26,11 +25,11 @@
:space-evenly false
:class "bar-container"
:vexpand true
- (workspaces-widget)
- (windows-widget)
+ (workspaces-widget :workspaces "${niri_status.workspaces}")
+ (windows-widget :windows "${niri_status.active_windows}")
(system-widget)))
-(defwidget workspaces-widget []
+(defwidget workspaces-widget [workspaces]
(box :orientation "v" :space-evenly false :spacing 4 :halign "fill" :class "workspaces"
(for ws in workspaces
(eventbox :onclick "niri msg action focus-workspace ${ws.idx}"
@@ -43,10 +42,9 @@
:image-width 20 :image-height 20)
(label :class "workspace-idx" :text "${ws.idx}" :halign "end" :valign "end")))))))
-(defwidget windows-widget []
+(defwidget windows-widget [windows]
(box :orientation "v" :space-evenly false :spacing 4 :halign "center" :class "windows" :vexpand true
-
- (for w in active-windows
+ (for w in windows
(eventbox :onclick "niri msg action focus-window --id ${w.id}"
:cursor "pointer"
:halign "fill"
diff --git a/modules/desktop/eww/niri-status.lua b/modules/desktop/eww/niri-status.lua
new file mode 100644
index 0000000..771a12c
--- /dev/null
+++ b/modules/desktop/eww/niri-status.lua
@@ -0,0 +1,254 @@
+#!/usr/bin/env luajit
+-- niri-status: emit {workspaces: [...], active_windows: [...]} as one JSON
+-- object per line, driven by a single niri event-stream subscription.
+--
+-- This replaces the old activate-windows.lua + workspaces.lua pair. Those
+-- ran as two separate long-lived processes, each independently subscribed
+-- to niri's event-stream and, on every relevant event, independently
+-- re-fetched both `niri msg -j windows` and `niri msg -j workspaces` via a
+-- shelled-out io.popen. Combined that was up to 8 process spawns per single
+-- event across both scripts. Here there's one event-stream subscription,
+-- and one shared windows/workspaces fetch per event, significantly reducing
+-- the total number of spawned processes and resting children.
+
+local cjson = require("cjson")
+
+local Gio, GioUnix, Gtk, Gdk, icon_theme = nil, nil, nil, nil, nil
+local DesktopAppInfo = nil
+do
+ local lgi_ok, lgi = pcall(require, "lgi")
+ if lgi_ok then
+ local gio_ok, gio = pcall(function() return lgi.Gio end)
+ if gio_ok then Gio = gio end
+
+ local giounix_ok, giounix = pcall(function() return lgi.GioUnix end)
+ if giounix_ok then GioUnix = giounix end
+
+ local gtk_ok, gtk = pcall(function() return lgi.Gtk end)
+ if gtk_ok then Gtk = gtk end
+
+ local gdk_ok, gdk = pcall(function() return lgi.Gdk end)
+ if gdk_ok then Gdk = gdk end
+
+ DesktopAppInfo = (GioUnix and GioUnix.DesktopAppInfo) or (Gio and Gio.DesktopAppInfo)
+
+ if Gtk then
+ local get_ok, theme = pcall(Gtk.IconTheme.get_default)
+ if get_ok and theme then
+ icon_theme = theme
+ elseif Gdk then
+ local disp_ok, display = pcall(Gdk.Display.get_default)
+ if disp_ok and display then
+ local get_ok2, theme2 = pcall(Gtk.IconTheme.get_for_display, display)
+ if get_ok2 and theme2 then
+ icon_theme = theme2
+ end
+ end
+ end
+ end
+ end
+end
+
+local icon_cache = {}
+
+local function g_list_to_table(gl)
+ if gl == nil then return {} end
+ if type(gl) == "table" then return gl end
+ local t = {}
+ local cur = gl
+ while cur do
+ t[#t + 1] = cur.data
+ cur = cur.next
+ end
+ return t
+end
+
+local function resolve_icon_path(app_id)
+ if not app_id or app_id == "" then return "" end
+ if icon_cache[app_id] then return icon_cache[app_id] end
+
+ local icon_path = ""
+ if DesktopAppInfo and Gtk and icon_theme then
+ local function try_desktop_app_info(desktop_id)
+ local ok, info = pcall(DesktopAppInfo.new, desktop_id)
+ if ok and info then return info end
+ return nil
+ end
+
+ local app_info = try_desktop_app_info(app_id .. ".desktop")
+ or try_desktop_app_info(string.lower(app_id) .. ".desktop")
+
+ if not app_info then
+ local lc = string.lower(app_id)
+ for _, info in ipairs(g_list_to_table(Gio.AppInfo.get_all())) do
+ if info:is_a(DesktopAppInfo) then
+ local wm = info:get_startup_wm_class()
+ if wm and string.lower(wm) == lc then
+ app_info = info
+ break
+ end
+ end
+ end
+ end
+
+ local gicon = app_info and app_info:get_icon()
+ if gicon then
+ local ok2, lookup = pcall(icon_theme.lookup_by_gicon, icon_theme, gicon, 24, Gtk.IconLookupFlags.FORCE_SIZE)
+ if ok2 and lookup then
+ icon_path = lookup:get_filename() or ""
+ end
+ end
+ if icon_path == "" then
+ local ok2, lookup = pcall(icon_theme.lookup_icon, icon_theme, app_id, 24, Gtk.IconLookupFlags.FORCE_SIZE)
+ if ok2 and lookup then
+ icon_path = lookup:get_filename() or ""
+ end
+ end
+ end
+
+ icon_cache[app_id] = icon_path
+ return icon_path
+end
+
+-- Safely run niri msg without crashing due to luaposix version changes
+local function niri_json(command)
+ local f = io.popen("niri msg -j " .. command .. " 2>/dev/null")
+ if not f then return nil end
+ local out = f:read("*a")
+ f:close()
+ local ok, decoded = pcall(cjson.decode, out)
+ if ok then return decoded end
+ return nil
+end
+
+local function window_sort_key(w)
+ local layout = w.layout or {}
+ local pos = layout.pos_in_scrolling_layout
+ if pos then return { 0, pos[1], pos[2] } end
+ return { 1, w.id, 0 }
+end
+
+local function lt(a, b)
+ for k = 1, math.max(#a, #b) do
+ local x, y = a[k], b[k]
+ if y == nil then return false end
+ if x == nil then return true end
+ if x ~= y then return x < y end
+ end
+ return false
+end
+
+-- Ensures cjson turns empty tables into `[]` instead of `{}`
+local empty_array = cjson.empty_array or setmetatable({}, { __jsontype = "array" })
+
+local function build_state()
+ local workspaces_raw = niri_json("workspaces") or {}
+ local windows_raw = niri_json("windows") or {}
+
+ local windows_by_id = {}
+ for _, w in ipairs(windows_raw) do
+ windows_by_id[w.id] = w
+ end
+
+ local active_id
+ for _, ws in ipairs(workspaces_raw) do
+ if ws.is_active then
+ active_id = ws.id
+ break
+ end
+ end
+
+ local on_active = {}
+ for _, w in ipairs(windows_raw) do
+ if w.workspace_id == active_id then
+ on_active[#on_active + 1] = w
+ end
+ end
+ table.sort(on_active, function(a, b)
+ return lt(window_sort_key(a), window_sort_key(b))
+ end)
+
+ local active_windows_result = {}
+ for _, w in ipairs(on_active) do
+ active_windows_result[#active_windows_result + 1] = {
+ id = w.id,
+ title = w.title or "",
+ app_id = w.app_id or "",
+ is_focused = w.is_focused or false,
+ icon = resolve_icon_path(w.app_id or ""),
+ }
+ end
+ -- Enforce JSON Array even if empty
+ if #active_windows_result == 0 then active_windows_result = empty_array end
+
+ local ws_list = {}
+ for _, ws in ipairs(workspaces_raw) do
+ ws_list[#ws_list + 1] = ws
+ end
+ table.sort(ws_list, function(a, b) return a.idx < b.idx end)
+
+ local workspaces_result = {}
+ for _, ws in ipairs(ws_list) do
+ local active_win = windows_by_id[ws.active_window_id]
+ local app_id = (active_win and active_win.app_id) or ""
+
+ local ws_name = (type(ws.name) == "string" and ws.name ~= "userdata: NULL" and ws.name) or ("Workspace " .. tostring(ws.idx))
+
+ local ws_windows = {}
+ for _, w in ipairs(windows_raw) do
+ if w.workspace_id == ws.id then
+ ws_windows[#ws_windows + 1] = w
+ end
+ end
+ table.sort(ws_windows, function(a, b)
+ return lt(window_sort_key(a), window_sort_key(b))
+ end)
+
+ local lines = { ws_name }
+ for idx, w in ipairs(ws_windows) do
+ lines[#lines + 1] = string.format("%d. %s: %s", idx, (w.app_id and w.app_id ~= "" and w.app_id) or "unknown", (w.title and w.title ~= "" and w.title) or "(untitled)")
+ end
+
+ workspaces_result[#workspaces_result + 1] = {
+ idx = ws.idx,
+ id = ws.id,
+ is_active = ws.is_active or false,
+ icon = resolve_icon_path(app_id),
+ tooltip = #ws_windows > 0 and table.concat(lines, "\n") or ws_name,
+ }
+ end
+ -- Enforce JSON Array even if empty
+ if #workspaces_result == 0 then workspaces_result = empty_array end
+
+ -- Output as a ROOT JSON OBJECT
+ return {
+ workspaces = workspaces_result,
+ active_windows = active_windows_result,
+ }
+end
+
+local function emit_state()
+ local ok, result = pcall(build_state)
+ if not ok then
+ io.stderr:write(tostring(result) .. "\n")
+ return
+ end
+ print(cjson.encode(result))
+ io.stdout:flush()
+end
+
+local RELEVANT_EVENTS = {
+ WindowOpenedOrChanged = true, WindowClosed = true, WindowFocusChanged = true,
+ WorkspaceActivated = true, WorkspacesChanged = true, WindowsChanged = true,
+ WorkspaceUrgencyChanged = true, WorkspaceActiveWindowChanged = true
+}
+
+local proc = io.popen("niri msg -j event-stream")
+emit_state()
+for line in proc:lines() do
+ local ok, obj = pcall(cjson.decode, line)
+ if ok and obj then
+ local key = next(obj)
+ if RELEVANT_EVENTS[key] then emit_state() end
+ end
+end
diff --git a/modules/desktop/eww/system-stats.lua b/modules/desktop/eww/system-stats.lua
new file mode 100644
index 0000000..514647f
--- /dev/null
+++ b/modules/desktop/eww/system-stats.lua
@@ -0,0 +1,826 @@
+#!/usr/bin/env luajit
+-- system-stats: emit {cpu, ram, net, sound} as JSON.
+--
+-- Single-threaded LuaJIT port of the original Python script. Instead of
+-- OS threads, a single supervisor loop schedules the periodic cpu/ram and
+-- sound refreshes and polls an inotifywait child for link-state changes.
+local cjson = require("cjson")
+local posix = require("posix")
+local unistd = require("posix.unistd")
+local gettimeofday = require("posix.sys.time").gettimeofday
+local poll = require("posix.poll").poll
+
+local NULL = cjson.null
+
+local function nonnull(v)
+ if v == nil then
+ return NULL
+ end
+ return v
+end
+
+-- Throughput deltas between cpu/ram refreshes
+local net_last_time = nil
+local net_last_bytes = {}
+
+-- ------------------------------------------------ time
+local function mono_sec()
+ local g = gettimeofday()
+ return g.tv_sec + g.tv_usec / 1e6
+end
+
+-- ------------------------------------------------ process helpers
+local function sh(cmd)
+ local h = io.popen(cmd)
+ if not h then
+ return nil
+ end
+ local out = h:read("*a")
+ h:close()
+ return out
+end
+
+-- ------------------------------------------------ formatting
+local function format_rate(bytes_per_sec)
+ if bytes_per_sec == nil or bytes_per_sec < 0 then
+ return "0B"
+ end
+ local units = { "B", "K", "M", "G", "T" }
+ local val = bytes_per_sec
+ local unit_idx = 1
+ while val >= 1024 and unit_idx < #units do
+ val = val / 1024.0
+ unit_idx = unit_idx + 1
+ end
+ local unit = units[unit_idx]
+ if val >= 100 or unit == "B" then
+ return string.format("%d%s", math.floor(val + 0.5), unit)
+ end
+ return string.format("%.1f%s", val, unit)
+end
+
+local function format_bytes(kib)
+ if kib == nil or kib < 0 then
+ return "0B"
+ end
+ local units = { "K", "M", "G", "T" }
+ local val = kib
+ local unit_idx = 1
+ while val >= 1024 and unit_idx < #units do
+ val = val / 1024.0
+ unit_idx = unit_idx + 1
+ end
+ local unit = units[unit_idx]
+ if val >= 100 or unit == "K" then
+ return string.format("%d%s", math.floor(val + 0.5), unit)
+ end
+ return string.format("%.1f%s", val, unit)
+end
+
+-- ------------------------------------------------ /proc readers
+local function read_cpu_times()
+ local times = {}
+ local f = io.open("/proc/stat")
+ if not f then
+ return times
+ end
+ for line in f:lines() do
+ if not line:match("^cpu") then
+ break
+ end
+ local parts = {}
+ for w in line:gmatch("%S+") do
+ parts[#parts + 1] = w
+ end
+ local label = parts[1]
+ local total, idle = 0, 0
+ for j = 2, #parts do
+ local v = tonumber(parts[j]) or 0
+ total = total + v
+ if j == 4 or j == 5 then
+ idle = idle + v
+ end
+ end
+ times[label] = { idle = idle, total = total }
+ end
+ f:close()
+ return times
+end
+
+local function get_cpu_freq_mhz()
+ local count, sum, ok = 0, 0, false
+ local f = io.open("/proc/cpuinfo")
+ if not f then
+ return nil
+ end
+ for line in f:lines() do
+ local low = line:lower()
+ if low:find("^cpu mhz") then
+ local val = line:match(":%s*([%d%.]+)")
+ if val then
+ sum = sum + tonumber(val)
+ count = count + 1
+ ok = true
+ end
+ end
+ end
+ f:close()
+ if not ok then
+ return nil
+ end
+ return math.floor(sum / count + 0.5)
+end
+
+local function get_load_avg()
+ local f = io.open("/proc/loadavg")
+ if not f then
+ return nil
+ end
+ local line = f:read("*l")
+ f:close()
+ if not line then
+ return nil
+ end
+ local a, b, c = line:match("^([%d%.]+)%s+([%d%.]+)%s+([%d%.]+)")
+ if not a then
+ return nil
+ end
+ return { tonumber(a), tonumber(b), tonumber(c) }
+end
+
+local function get_ram_speed_mhz()
+ local out = sh("dmidecode -t 17 2>/dev/null")
+ if not out then
+ return nil
+ end
+ local speeds = {}
+ for line in out:gmatch("[^\n]+") do
+ local s = line:match("^%s*Configured Memory Speed:%s*(.-)%s*$")
+ if s then
+ local low = s:lower()
+ if low ~= "unknown" and low ~= "no module installed" then
+ local num = s:match("^(%d+)")
+ if num then
+ speeds[#speeds + 1] = tonumber(num)
+ end
+ end
+ end
+ end
+ if #speeds == 0 then
+ for line in out:gmatch("[^\n]+") do
+ local s = line:match("^%s*Speed:%s*(.-)%s*$")
+ if s then
+ local low = s:lower()
+ if low ~= "unknown" and low ~= "no module installed" then
+ local num = s:match("^(%d+)")
+ if num then
+ speeds[#speeds + 1] = tonumber(num)
+ end
+ end
+ end
+ end
+ end
+ if #speeds == 0 then
+ return nil
+ end
+ local mx = speeds[1]
+ for j = 2, #speeds do
+ if speeds[j] > mx then
+ mx = speeds[j]
+ end
+ end
+ return mx
+end
+
+-- ------------------------------------------------ network helpers
+local function get_all_default_ifaces()
+ local out = sh("ip -o route show default 2>/dev/null")
+ if not out then
+ return {}
+ end
+ local ifaces = {}
+ local seen = {}
+ for line in out:gmatch("[^\n]+") do
+ local iface = line:match("dev (%S+)")
+ if iface and not seen[iface] then
+ seen[iface] = true
+ ifaces[#ifaces + 1] = iface
+ end
+ end
+ return ifaces
+end
+
+local function get_iface_ip(iface)
+ if not iface or iface == "" then
+ return nil
+ end
+ local out = sh("ip -o -4 addr show dev " .. iface .. " 2>/dev/null")
+ if not out then
+ return nil
+ end
+ local ip = out:match("inet (%d+%.%d+%.%d+%.%d+)")
+ return ip
+end
+
+local function get_operstate(iface)
+ local f = io.open("/sys/class/net/" .. iface .. "/operstate")
+ if not f then
+ return "down"
+ end
+ local s = f:read("*l")
+ f:close()
+ return (s and s:match("^%s*(.-)%s*$")) or "down"
+end
+
+local function get_iface_type(iface)
+ if posix.stat("/sys/class/net/" .. iface .. "/wireless") then
+ return "wifi"
+ end
+ local st = posix.stat("/sys/class/net/" .. iface .. "/bridge")
+ if st and st.type == "directory" then
+ return "bridge"
+ end
+ if posix.stat("/sys/class/net/" .. iface .. "/tun_flags") then
+ return "tun"
+ end
+ return "ethernet"
+end
+
+local function get_speed_mbps(iface, iftype)
+ if iftype == "ethernet" then
+ local f = io.open("/sys/class/net/" .. iface .. "/speed")
+ if f then
+ local s = f:read("*l")
+ f:close()
+ local num = s and tonumber(s:match("^%s*(-?%d+)"))
+ if num and num >= 0 then
+ return num
+ end
+ end
+ return nil
+ end
+ if iftype == "wifi" then
+ local out = sh("iw dev " .. iface .. " link 2>/dev/null")
+ if not out then
+ return nil
+ end
+ for line in out:gmatch("[^\n]+") do
+ local l = line:match("^%s*tx bitrate:%s*(.-)%s*$")
+ if l then
+ local num = tonumber(l:match("^([%d%.]+)"))
+ if num then
+ return math.floor(num + 0.5)
+ end
+ end
+ end
+ end
+ return nil
+end
+
+local function get_iface_io_bytes(iface)
+ if not iface or iface == "" then
+ return nil, nil
+ end
+ local f = io.open("/proc/net/dev")
+ if not f then
+ return nil, nil
+ end
+ local rx, tx
+ for line in f:lines() do
+ local colon = line:find(":")
+ if colon then
+ local dev = line:sub(1, colon - 1):match("^%s*(.-)%s*$")
+ if dev == iface then
+ local fields = {}
+ for w in line:sub(colon + 1):gmatch("%S+") do
+ fields[#fields + 1] = w
+ end
+ rx = tonumber(fields[1]) or nil
+ tx = tonumber(fields[9]) or nil
+ break
+ end
+ end
+ end
+ f:close()
+ return rx, tx
+end
+
+-- ------------------------------------------------ tooltips
+local function build_cpu_tooltip(overall_pct, per_core_list, freq_mhz, load_avg)
+ local lines = { string.format("CPU: %d%%", overall_pct) }
+ if freq_mhz ~= nil then
+ lines[#lines + 1] = string.format("Freq: %d MHz", freq_mhz)
+ end
+ if load_avg ~= nil then
+ lines[#lines + 1] = string.format(
+ "Load: %.2f %.2f %.2f",
+ load_avg[1],
+ load_avg[2],
+ load_avg[3]
+ )
+ end
+ for i, pct in ipairs(per_core_list) do
+ lines[#lines + 1] = string.format("Core %d: %d%%", i - 1, pct)
+ end
+ return table.concat(lines, "\n")
+end
+
+local function build_ram_tooltip(ram_pct, used, total, available, swap_pct, swap_used, swap_total, speed_mhz)
+ local lines = { string.format("RAM: %d%% (%s / %s)", ram_pct, used, total) }
+ lines[#lines + 1] = "Available: " .. available
+ lines[#lines + 1] = string.format("Swap: %d%% (%s / %s)", swap_pct, swap_used, swap_total)
+ if speed_mhz ~= nil then
+ lines[#lines + 1] = string.format("Speed: %d MHz", speed_mhz)
+ else
+ lines[#lines + 1] = "Speed: unknown"
+ end
+ return table.concat(lines, "\n")
+end
+
+local function build_sound_tooltip(percent, mute, sink)
+ local vol = tostring(percent) .. "%"
+ local lines = {}
+ if mute then
+ lines[#lines + 1] = string.format("Muted (%s)", vol)
+ else
+ lines[#lines + 1] = "Volume: " .. vol
+ end
+ if sink then
+ lines[#lines + 1] = "Sink: " .. sink
+ end
+ return table.concat(lines, "\n")
+end
+
+-- ------------------------------------------------ state collectors
+-- get_all_default_ifaces (`ip route`), get_iface_ip (`ip addr`), and the
+-- wifi branch of get_speed_mbps (`iw dev link`) each spawn a subprocess.
+-- Those three rarely change second-to-second, so they're only re-probed
+-- every NET_SLOW_REFRESH_INTERVAL instead of on every check_net_status()
+-- call. operstate, iface type, ethernet speed, and rx/tx byte counters are
+-- all plain sysfs/proc reads with no subprocess involved, so they stay
+-- live on every call regardless -- rx/tx throughput in particular needs
+-- to stay frequent to be useful.
+local NET_SLOW_REFRESH_INTERVAL = 15.0
+local net_slow_ifaces = nil
+local net_slow_ip = {}
+local net_slow_wifi_speed = {}
+local net_slow_expires_at = 0
+
+local function refresh_net_slow_cache(now)
+ net_slow_ifaces = get_all_default_ifaces()
+ local ip_cache, wifi_speed_cache = {}, {}
+ for _, iface in ipairs(net_slow_ifaces) do
+ ip_cache[iface] = get_iface_ip(iface)
+ if get_iface_type(iface) == "wifi" then
+ wifi_speed_cache[iface] = get_speed_mbps(iface, "wifi")
+ end
+ end
+ net_slow_ip = ip_cache
+ net_slow_wifi_speed = wifi_speed_cache
+ net_slow_expires_at = now + NET_SLOW_REFRESH_INTERVAL
+end
+
+-- Pass force=true to bypass the timer and re-probe immediately (used for
+-- the initial call and whenever inotify reports an actual link change).
+local function check_net_status(force)
+ local now = mono_sec()
+ if force or net_slow_ifaces == nil or now >= net_slow_expires_at then
+ refresh_net_slow_cache(now)
+ end
+
+ local ifaces = net_slow_ifaces
+
+ if #ifaces == 0 then
+ return { status = "down", tooltip = "Network Offline", routes = {} }
+ end
+
+ local routes = {}
+ local any_up = false
+ local any_limited = false
+
+ for _, iface in ipairs(ifaces) do
+ local oper = get_operstate(iface)
+ if oper == "up" then
+ any_up = true
+ elseif oper == "dormant" then
+ any_limited = true
+ end
+
+ local iftype = get_iface_type(iface)
+ local ip_addr = net_slow_ip[iface]
+ local speed
+ if oper == "up" then
+ if iftype == "wifi" then
+ speed = net_slow_wifi_speed[iface]
+ else
+ -- Ethernet speed is a plain sysfs read (no subprocess), so it
+ -- stays live every call rather than going through the slow cache.
+ speed = get_speed_mbps(iface, iftype)
+ end
+ end
+
+ local rx_bytes, tx_bytes = get_iface_io_bytes(iface)
+ local rx_str, tx_str = nil, nil
+
+ if oper ~= "down" and rx_bytes ~= nil and tx_bytes ~= nil then
+ if net_last_time ~= nil and net_last_bytes[iface] then
+ local dt = now - net_last_time
+ if dt > 0 then
+ local prev = net_last_bytes[iface]
+ local rx_rate = math.max(0, (rx_bytes - prev[1]) / dt)
+ local tx_rate = math.max(0, (tx_bytes - prev[2]) / dt)
+ rx_str = format_rate(rx_rate)
+ tx_str = format_rate(tx_rate)
+ end
+ end
+ net_last_bytes[iface] = { rx_bytes, tx_bytes }
+ else
+ rx_str, tx_str = nil, nil
+ end
+
+ routes[#routes + 1] = {
+ iface = iface,
+ ip = nonnull(ip_addr),
+ ["type"] = iftype,
+ speed = nonnull(speed),
+ rx = nonnull(rx_str),
+ tx = nonnull(tx_str),
+ }
+ end
+
+ net_last_time = now
+
+ local overall_status
+ if any_up then
+ overall_status = "up"
+ elseif any_limited then
+ overall_status = "limited"
+ else
+ overall_status = "down"
+ end
+
+ local tooltip
+ if overall_status == "down" or #routes == 0 then
+ tooltip = "Network Offline"
+ else
+ local first = routes[1]
+ local rx = first.rx
+ local tx = first.tx
+ if rx == NULL or rx == nil then
+ rx = "-"
+ end
+ if tx == NULL or tx == nil then
+ tx = "-"
+ end
+ tooltip = string.format("\u{2193}%s \u{2191}%s", rx, tx)
+ for _, r in ipairs(routes) do
+ local iface = r.iface or "unknown"
+ local ip = r.ip
+ if ip == NULL or ip == nil then
+ ip = "no ip"
+ end
+ tooltip = tooltip .. "\n" .. iface .. ": " .. ip
+ end
+ end
+
+ return { status = overall_status, tooltip = tooltip, routes = routes }
+end
+
+local function get_sound_state()
+ local function pamixer_flag(flag)
+ local out = sh("pamixer " .. flag .. " 2>/dev/null")
+ if not out then
+ return nil
+ end
+ return out:gsub("^%s+", ""):gsub("%s+$", "")
+ end
+
+ local percent, mute, sink = nil, nil, nil
+
+ local pout = pamixer_flag("--get-volume")
+ if pout then
+ local num = tonumber(pout:match("^(%-?%d+)"))
+ if num then
+ percent = math.max(0, math.min(100, num))
+ end
+ end
+
+ local mout = pamixer_flag("--get-mute")
+ if mout ~= nil then
+ mute = (mout:lower() == "true")
+ end
+
+ local sout = pamixer_flag("--get-default-sink")
+ if sout then
+ -- Output ends with: INDEX "name" "description"
+ local names = {}
+ for q in sout:gmatch('"([^"]*)"') do
+ names[#names + 1] = q
+ end
+ if #names > 0 then
+ sink = names[#names]
+ else
+ local last = sout:match("([^\n]+)$")
+ sink = last or sout
+ end
+ end
+
+ local vol = tostring(percent) .. "%"
+ local supp = {}
+ if mute then
+ supp[#supp + 1] = string.format("Muted (%s)", vol)
+ else
+ supp[#supp + 1] = "Volume: " .. vol
+ end
+ if sink then
+ supp[#supp + 1] = "Sink: " .. sink
+ end
+
+ return {
+ percent = nonnull(percent),
+ mute = mute or false,
+ sink = nonnull(sink),
+ tooltip = table.concat(supp, "\n"),
+ }
+end
+
+-- ------------------------------------------------ state + emit
+local state = {
+ cpu = {
+ percent = 0,
+ per_core = {},
+ freq_mhz = NULL,
+ load_avg = NULL,
+ tooltip = "CPU: 0%",
+ },
+ ram = {
+ percent = 0,
+ used = "0B",
+ total = "0B",
+ available = "0B",
+ swap_percent = 0,
+ swap_used = "0B",
+ swap_total = "0B",
+ speed_mhz = NULL,
+ tooltip = "RAM: 0%",
+ },
+ net = {
+ status = "down",
+ tooltip = "Network Offline",
+ routes = {},
+ },
+ sound = {
+ percent = NULL,
+ mute = false,
+ sink = NULL,
+ tooltip = "Volume: unknown",
+ },
+}
+
+local function emit()
+ io.write(cjson.encode(state) .. "\n")
+ io.stdout:flush()
+end
+
+local CPU_INTERVAL = 2.0
+local SOUND_INTERVAL = 1.0
+local INOTIFY_RETRY_INTERVAL = 30.0 -- backoff between (re)spawn attempts
+
+-- ------------------------------------------------ inotifywait child
+-- Returns pid, read_fd on success, or nil, nil if the fork/exec setup
+-- itself failed outright (pipe/fork syscall failure, not inotifywait
+-- failing later).
+local function spawn_inotify()
+ local r, w = posix.pipe()
+ if not r or not w then
+ return nil, nil
+ end
+ local pid = posix.fork()
+ if pid == nil then
+ posix.close(r)
+ posix.close(w)
+ return nil, nil
+ end
+ if pid == 0 then
+ posix.dup2(w, unistd.STDOUT_FILENO)
+ local nullfd = posix.open("/dev/null", posix.O_WRONLY)
+ if nullfd then
+ posix.dup2(nullfd, unistd.STDERR_FILENO)
+ posix.close(nullfd)
+ end
+ posix.close(r)
+ posix.close(w)
+ local ok, err = posix.execp(
+ "inotifywait",
+ { "inotifywait", "-m", "-e", "modify", "/run/systemd/netif/state" }
+ )
+ if not ok then
+ io.stderr:write("inotifywait: " .. tostring(err) .. "\n")
+ os.exit(1)
+ end
+ end
+ posix.close(w)
+ return pid, r
+end
+
+-- ------------------------------------------------ main loop
+local function main()
+ local ram_speed_mhz = get_ram_speed_mhz()
+
+ -- seed global state copy with all keys (null defaults)
+ local prev_cpu_times = read_cpu_times()
+
+ -- initial emission
+ state.net = check_net_status(true)
+ emit()
+
+ -- net_fd is nil whenever we have no live inotifywait child to poll on.
+ -- We (re)spawn opportunistically, but never more often than
+ -- INOTIFY_RETRY_INTERVAL, so a persistently-failing child (missing
+ -- binary, watched path doesn't exist, permissions, ...) can never turn
+ -- into a tight loop -- it just falls back to picking up network
+ -- changes on the regular CPU_INTERVAL cadence below instead.
+ local inotify_pid = nil
+ local net_fd = nil
+ local next_inotify_attempt = 0 -- attempt immediately on first iteration
+
+ local next_cpu = mono_sec() + CPU_INTERVAL
+ local next_sound = mono_sec() + SOUND_INTERVAL
+
+ while true do
+ local now = mono_sec()
+
+ if net_fd == nil and now >= next_inotify_attempt then
+ inotify_pid, net_fd = spawn_inotify()
+ -- Whether this attempt succeeded or not, don't try again for a
+ -- while: a child that exits immediately (e.g. because the watched
+ -- path doesn't exist) would otherwise cause an attempt-per-loop
+ -- storm identical to the original bug.
+ next_inotify_attempt = mono_sec() + INOTIFY_RETRY_INTERVAL
+ end
+
+ if now >= next_cpu then
+ local cur_times = read_cpu_times()
+ local overall_pct = 0
+ local per_core = {}
+ for label, cur in pairs(cur_times) do
+ local prev = prev_cpu_times[label] or cur
+ local d_idle = cur.idle - prev.idle
+ local d_total = cur.total - prev.total
+ local pct = 0
+ if d_total > 0 then
+ pct = math.floor((1 - d_idle / d_total) * 100 + 0.5)
+ end
+ if label == "cpu" then
+ overall_pct = pct
+ else
+ per_core[tonumber(label:match("^cpu(.*)$")) or -1] = pct
+ end
+ end
+ prev_cpu_times = cur_times
+
+ local keys = {}
+ for k in pairs(per_core) do
+ keys[#keys + 1] = k
+ end
+ table.sort(keys)
+ local per_core_list = {}
+ for _, k in ipairs(keys) do
+ per_core_list[#per_core_list + 1] = per_core[k]
+ end
+
+ local freq_mhz = get_cpu_freq_mhz()
+ local load_avg = get_load_avg()
+
+ local cpu_state = {
+ percent = overall_pct,
+ per_core = per_core_list,
+ freq_mhz = nonnull(freq_mhz),
+ load_avg = nonnull(load_avg),
+ tooltip = build_cpu_tooltip(overall_pct, per_core_list, freq_mhz, load_avg),
+ }
+
+ -- RAM
+ local meminfo = {}
+ local f = io.open("/proc/meminfo")
+ if f then
+ for line in f:lines() do
+ local k, v = line:match("^([^:]+):%s*(%d+)")
+ if k and v then
+ meminfo[k] = tonumber(v)
+ end
+ end
+ f:close()
+ end
+
+ local total_kb = meminfo.MemTotal or 0
+ local avail_kb = meminfo.MemAvailable or 0
+ local used_kb = math.max(0, total_kb - avail_kb)
+ local ram_pct = 0
+ if total_kb > 0 then
+ ram_pct = math.floor((1 - avail_kb / total_kb) * 100 + 0.5)
+ end
+
+ local swap_total_kb = meminfo.SwapTotal or 0
+ local swap_free_kb = meminfo.SwapFree or 0
+ local swap_used_kb = math.max(0, swap_total_kb - swap_free_kb)
+ local swap_pct = 0
+ if swap_total_kb > 0 then
+ swap_pct = math.floor(swap_used_kb / swap_total_kb * 100 + 0.5)
+ end
+
+ local used_fmt = format_bytes(used_kb)
+ local total_fmt = format_bytes(total_kb)
+ local avail_fmt = format_bytes(avail_kb)
+ local swap_used_fmt = format_bytes(swap_used_kb)
+ local swap_total_fmt = format_bytes(swap_total_kb)
+
+ local ram_state = {
+ percent = ram_pct,
+ used = used_fmt,
+ total = total_fmt,
+ available = avail_fmt,
+ swap_percent = swap_pct,
+ swap_used = swap_used_fmt,
+ swap_total = swap_total_fmt,
+ speed_mhz = nonnull(ram_speed_mhz),
+ tooltip = build_ram_tooltip(
+ ram_pct,
+ used_fmt,
+ total_fmt,
+ avail_fmt,
+ swap_pct,
+ swap_used_fmt,
+ swap_total_fmt,
+ ram_speed_mhz
+ ),
+ }
+
+ state.cpu = cpu_state
+ state.ram = ram_state
+ -- Net status is always refreshed here too, so even with inotify
+ -- permanently unavailable we still pick up link changes within
+ -- CPU_INTERVAL seconds.
+ state.net = check_net_status()
+ emit()
+ next_cpu = mono_sec() + CPU_INTERVAL
+ end
+
+ if now >= next_sound then
+ state.sound = get_sound_state()
+ emit()
+ next_sound = mono_sec() + SOUND_INTERVAL
+ end
+
+ -- Poll inotifywait for link-state changes (timeout in ms). If we
+ -- have no live child right now, still sleep until the next
+ -- scheduled piece of work (or the next respawn attempt) instead of
+ -- busy-looping.
+ local wake_at = math.min(next_cpu, next_sound)
+ if net_fd == nil then
+ wake_at = math.min(wake_at, next_inotify_attempt)
+ end
+ local until_next = wake_at - mono_sec()
+ if until_next < 0 then
+ until_next = 0
+ end
+ local timeout_ms = math.floor(until_next * 1000 + 0.5)
+
+ local fds = {}
+ if net_fd ~= nil then
+ fds[net_fd] = { events = { IN = true } }
+ end
+
+ local nready = poll(fds, timeout_ms)
+ if net_fd ~= nil and nready and nready > 0 then
+ local revents = fds[net_fd].revents
+ if revents and (revents.IN or revents.HUP or revents.ERR) then
+ local data = unistd.read(net_fd, 8192)
+ if data and #data > 0 then
+ -- An actual link-state change: force a full re-probe rather
+ -- than waiting out the slow-refresh timer, since this is
+ -- exactly the moment the cached IP/speed could be stale.
+ state.net = check_net_status(true)
+ emit()
+ else
+ -- Empty read on a "ready" fd means the write end closed, i.e.
+ -- the inotifywait child exited (crashed, watched path missing,
+ -- binary not found, etc). Reap it, drop the fd, and don't
+ -- touch it again until the next backed-off retry -- this is
+ -- the fix for the tight busy-loop: without this branch, poll()
+ -- would report this dead fd as "ready" forever, on every
+ -- single iteration, with an effectively-zero timeout.
+ unistd.close(net_fd)
+ if inotify_pid then
+ pcall(posix.wait, inotify_pid)
+ end
+ net_fd = nil
+ inotify_pid = nil
+ next_inotify_attempt = mono_sec() + INOTIFY_RETRY_INTERVAL
+ end
+ end
+ end
+ end
+end
+
+main()
diff --git a/modules/desktop/eww/system-stats.py b/modules/desktop/eww/system-stats.py
deleted file mode 100644
index fdc1503..0000000
--- a/modules/desktop/eww/system-stats.py
+++ /dev/null
@@ -1,587 +0,0 @@
-#!/usr/bin/env python3
-"""system-stats: emit {cpu, ram, net} as JSON.
-
-cpu is now an object: {
- "percent": 23, # overall utilization, 0-100
- "per_core": [12, 34, ...], # per-core utilization, 0-100 each
- "freq_mhz": 2400, # average current frequency across cores
- "load_avg": [0.5, 0.6, 0.7] # 1/5/15 min load averages
-}
-
-ram is now an object: {
- "percent": 42, # used %, based on MemAvailable
- "used": "6.2G", # formatted absolute used
- "total": "16.0G", # formatted absolute total
- "available": "9.8G", # formatted absolute available
- "swap_percent": 5,
- "swap_used": "512M",
- "swap_total": "8.0G",
- "speed_mhz": 3200 # configured RAM speed, or null if unavailable
-}
-
-net is an object: {
- "status": "up"|"limited"|"down",
- "tooltip": "↓12M ↑5M\\nwlan0: 192.168.1.5",
- "routes": [
- {
- "iface": "wlan0",
- "ip": "192.168.1.5",
- "type": "wifi",
- "speed": 866, # link speed in Mbps
- "rx": "12.5M", # formatted download rate
- "tx": "540K" # formatted upload rate
- },
- ...
- ]
-}
-
-sound is an object: {
- "percent": 58, # default sink volume, 0-100
- "mute": false, # whether the default sink is muted
- "sink": "Rockerz 430", # description of the default sink, or null
- "tooltip": "Volume: 58%\\nSink: Rockerz 430"
-}
-
-Requires `iw` (for wifi link speed), `iproute2` (for route/ip lookup)
-and `pamixer` (for volume/mute) on PATH.
-RAM speed detection requires `dmidecode`, which typically needs root privileges
-(run as root, grant CAP_SYS_RAWIO, or add a passwordless sudo rule) — if it's
-unavailable or unreadable, "speed_mhz" is simply reported as null.
-"""
-import json
-import os
-import re
-import subprocess
-import threading
-import time
-
-STATE_LOCK = threading.Lock()
-STATE = {
- "cpu": {
- "percent": 0,
- "per_core": [],
- "freq_mhz": None,
- "load_avg": None,
- "tooltip": "CPU: 0%",
- },
- "ram": {
- "percent": 0,
- "used": "0B",
- "total": "0B",
- "available": "0B",
- "swap_percent": 0,
- "swap_used": "0B",
- "swap_total": "0B",
- "speed_mhz": None,
- "tooltip": "RAM: 0%",
- },
- "net": {
- "status": "down",
- "tooltip": "Network Offline",
- "routes": [],
- },
- "sound": {
- "percent": None,
- "mute": False,
- "sink": None,
- "tooltip": "Volume: unknown",
- },
-}
-
-# Module-level tracking for throughput calculations
-NET_LAST_TIME = None
-NET_LAST_BYTES = {} # {iface: (rx_bytes, tx_bytes)}
-
-
-def format_rate(bytes_per_sec):
- """Format bytes/sec into an ultra-compact 3-5 character string (e.g., '500B', '1.2M')."""
- if bytes_per_sec is None or bytes_per_sec < 0:
- return "0B"
-
- units = ["B", "K", "M", "G", "T"]
- val = float(bytes_per_sec)
- unit_idx = 0
-
- while val >= 1024 and unit_idx < len(units) - 1:
- val /= 1024.0
- unit_idx += 1
-
- unit = units[unit_idx]
-
- if val >= 100 or unit == "B":
- return f"{int(val)}{unit}"
- else:
- return f"{val:.1f}{unit}"
-
-
-def format_bytes(kib):
- """Format an absolute value given in KiB into a compact human-readable
- string (e.g., '6.2G', '512M'). Same style as format_rate, but for
- absolute quantities rather than a per-second rate."""
- if kib is None or kib < 0:
- return "0B"
-
- units = ["K", "M", "G", "T"]
- val = float(kib)
- unit_idx = 0
-
- while val >= 1024 and unit_idx < len(units) - 1:
- val /= 1024.0
- unit_idx += 1
-
- unit = units[unit_idx]
-
- if val >= 100 or unit == "K":
- return f"{int(val)}{unit}"
- else:
- return f"{val:.1f}{unit}"
-
-
-def get_iface_io_bytes(iface):
- """Read cumulative rx/tx bytes for a specific interface from /proc/net/dev."""
- if not iface:
- return None, None
- try:
- with open("/proc/net/dev") as f:
- for line in f:
- if ":" not in line:
- continue
- dev, data = line.split(":", 1)
- if dev.strip() == iface:
- fields = data.split()
- return int(fields[0]), int(fields[8])
- except (OSError, ValueError, IndexError):
- pass
- return None, None
-
-
-def read_cpu_times():
- """Return {'cpu': (idle, total), 'cpu0': (idle, total), ...} for the
- aggregate and every individual core, parsed from /proc/stat."""
- times = {}
- with open("/proc/stat") as f:
- for line in f:
- if not line.startswith("cpu"):
- break
- parts = line.split()
- label = parts[0]
- vals = list(map(int, parts[1:]))
- idle = vals[3] + vals[4]
- total = sum(vals)
- times[label] = (idle, total)
- return times
-
-
-def get_cpu_freq_mhz():
- """Average current CPU frequency (MHz) across cores, from /proc/cpuinfo."""
- freqs = []
- try:
- with open("/proc/cpuinfo") as f:
- for line in f:
- if line.lower().startswith("cpu mhz"):
- try:
- freqs.append(float(line.split(":", 1)[1].strip()))
- except ValueError:
- pass
- except OSError:
- pass
- if not freqs:
- return None
- return round(sum(freqs) / len(freqs))
-
-
-def get_load_avg():
- try:
- with open("/proc/loadavg") as f:
- parts = f.read().split()
- return [float(parts[0]), float(parts[1]), float(parts[2])]
- except (OSError, ValueError, IndexError):
- return None
-
-
-def get_ram_speed_mhz():
- """Query DMI tables (SMBIOS type 17) for the configured/current memory
- speed via dmidecode. Requires root — returns None if dmidecode is
- missing, unreadable (permission denied), or reports no populated slots.
- This only needs to be queried once at startup since it doesn't change."""
- try:
- out = subprocess.check_output(
- ["dmidecode", "-t", "17"], text=True, stderr=subprocess.DEVNULL
- )
- except (subprocess.CalledProcessError, FileNotFoundError, PermissionError):
- return None
-
- speeds = []
- for line in out.splitlines():
- line = line.strip()
- if line.startswith("Configured Memory Speed:"):
- val = line.split(":", 1)[1].strip()
- if val.lower() in ("unknown", "no module installed"):
- continue
- try:
- speeds.append(int(val.split()[0]))
- except (ValueError, IndexError):
- continue
-
- if not speeds:
- # Fall back to rated "Speed" if no configured speed was reported
- for line in out.splitlines():
- line = line.strip()
- if line.startswith("Speed:"):
- val = line.split(":", 1)[1].strip()
- if val.lower() in ("unknown", "no module installed"):
- continue
- try:
- speeds.append(int(val.split()[0]))
- except (ValueError, IndexError):
- continue
-
- return max(speeds) if speeds else None
-
-
-def build_cpu_tooltip(overall_pct, per_core_list, freq_mhz, load_avg):
- lines = [f"CPU: {overall_pct}%"]
- if freq_mhz is not None:
- lines.append(f"Freq: {freq_mhz} MHz")
- if load_avg is not None:
- l1, l5, l15 = load_avg
- lines.append(f"Load: {l1:.2f} {l5:.2f} {l15:.2f}")
- for i, pct in enumerate(per_core_list):
- lines.append(f"Core {i}: {pct}%")
- return "\n".join(lines)
-
-
-def build_ram_tooltip(ram_pct, used, total, available, swap_pct, swap_used, swap_total, speed_mhz):
- lines = [f"RAM: {ram_pct}% ({used} / {total})"]
- lines.append(f"Available: {available}")
- lines.append(f"Swap: {swap_pct}% ({swap_used} / {swap_total})")
- lines.append(f"Speed: {speed_mhz} MHz" if speed_mhz is not None else "Speed: unknown")
- return "\n".join(lines)
-
-
-def build_sound_tooltip(percent, mute, sink):
- vol = f"{percent}%" if percent is not None else "unknown"
- lines = [f"Muted ({vol})" if mute else f"Volume: {vol}"]
- if sink:
- lines.append(f"Sink: {sink}")
- return "\n".join(lines)
-
-
-def get_sound_state():
- """Query the default sink's volume, mute state, and description via
- pamixer. Any unavailable field is reported as None/False."""
- def pamixer_flag(flag):
- try:
- return subprocess.check_output(
- ["pamixer", flag], text=True, stderr=subprocess.DEVNULL
- ).strip()
- except (subprocess.CalledProcessError, FileNotFoundError):
- return None
-
- percent, mute, sink = None, None, None
-
- out = pamixer_flag("--get-volume")
- if out:
- try:
- percent = max(0, min(100, int(out)))
- except ValueError:
- percent = None
-
- out = pamixer_flag("--get-mute")
- if out is not None:
- mute = out.lower() == "true"
-
- out = pamixer_flag("--get-default-sink")
- if out:
- # Output ends with: INDEX "name" "description"
- names = re.findall(r'"([^"]*)"', out)
- sink = names[-1] if names else out.splitlines()[-1]
-
- return {
- "percent": percent,
- "mute": mute,
- "sink": sink,
- "tooltip": build_sound_tooltip(percent, mute, sink),
- }
-
-
-def cpu_ram_loop(interval=2.0):
- prev_times = read_cpu_times()
- # Static for the life of the process — no need to shell out every tick.
- ram_speed_mhz = get_ram_speed_mhz()
-
- while True:
- time.sleep(interval)
-
- # --- CPU ---
- cur_times = read_cpu_times()
- overall_pct = 0
- per_core = {}
- for label, (idle, total) in cur_times.items():
- prev_idle, prev_total = prev_times.get(label, (idle, total))
- d_idle = idle - prev_idle
- d_total = total - prev_total
- pct = round((1 - d_idle / d_total) * 100) if d_total else 0
- if label == "cpu":
- overall_pct = pct
- else:
- per_core[int(label[len("cpu"):])] = pct
- prev_times = cur_times
- per_core_list = [per_core[i] for i in sorted(per_core)]
-
- freq_mhz = get_cpu_freq_mhz()
- load_avg = get_load_avg()
- cpu_state = {
- "percent": overall_pct,
- "per_core": per_core_list,
- "freq_mhz": freq_mhz,
- "load_avg": load_avg,
- "tooltip": build_cpu_tooltip(overall_pct, per_core_list, freq_mhz, load_avg),
- }
-
- # --- RAM ---
- with open("/proc/meminfo") as f:
- meminfo = {}
- for line in f:
- k, v, *_ = line.split()
- meminfo[k.rstrip(":")] = int(v)
-
- total_kb = meminfo.get("MemTotal", 0)
- avail_kb = meminfo.get("MemAvailable", 0)
- used_kb = max(0, total_kb - avail_kb)
- ram_pct = round((1 - avail_kb / total_kb) * 100) if total_kb else 0
-
- swap_total_kb = meminfo.get("SwapTotal", 0)
- swap_free_kb = meminfo.get("SwapFree", 0)
- swap_used_kb = max(0, swap_total_kb - swap_free_kb)
- swap_pct = round((swap_used_kb / swap_total_kb) * 100) if swap_total_kb else 0
-
- used_fmt = format_bytes(used_kb)
- total_fmt = format_bytes(total_kb)
- avail_fmt = format_bytes(avail_kb)
- swap_used_fmt = format_bytes(swap_used_kb)
- swap_total_fmt = format_bytes(swap_total_kb)
- ram_state = {
- "percent": ram_pct,
- "used": used_fmt,
- "total": total_fmt,
- "available": avail_fmt,
- "swap_percent": swap_pct,
- "swap_used": swap_used_fmt,
- "swap_total": swap_total_fmt,
- "speed_mhz": ram_speed_mhz,
- "tooltip": build_ram_tooltip(
- ram_pct, used_fmt, total_fmt, avail_fmt,
- swap_pct, swap_used_fmt, swap_total_fmt, ram_speed_mhz,
- ),
- }
-
- # --- Network ---
- net_status = check_net_status()
-
- with STATE_LOCK:
- STATE["cpu"] = cpu_state
- STATE["ram"] = ram_state
- STATE["net"] = net_status
- emit()
-
-
-def get_all_default_ifaces():
- """Returns a list of unique interfaces carrying default routes."""
- try:
- out = subprocess.check_output(
- ["ip", "-o", "route", "show", "default"],
- text=True, stderr=subprocess.DEVNULL,
- )
- except (subprocess.CalledProcessError, FileNotFoundError):
- return []
-
- ifaces = []
- for line in out.splitlines():
- parts = line.split()
- if "dev" in parts:
- iface = parts[parts.index("dev") + 1]
- if iface not in ifaces:
- ifaces.append(iface)
- return ifaces
-
-
-def get_iface_ip(iface):
- """Get the primary IPv4 address for a given interface."""
- if not iface:
- return None
- try:
- out = subprocess.check_output(
- ["ip", "-o", "-4", "addr", "show", "dev", iface],
- text=True, stderr=subprocess.DEVNULL,
- )
- for line in out.splitlines():
- parts = line.split()
- if "inet" in parts:
- cidr = parts[parts.index("inet") + 1]
- return cidr.split("/")[0]
- except (subprocess.CalledProcessError, FileNotFoundError, IndexError):
- pass
- return None
-
-
-def get_operstate(iface):
- try:
- with open(f"/sys/class/net/{iface}/operstate") as f:
- return f.read().strip()
- except OSError:
- return "down"
-
-
-def get_iface_type(iface):
- if os.path.exists(f"/sys/class/net/{iface}/wireless"):
- return "wifi"
- if os.path.isdir(f"/sys/class/net/{iface}/bridge"):
- return "bridge"
- if os.path.exists(f"/sys/class/net/{iface}/tun_flags"):
- return "tun"
- return "ethernet"
-
-
-def get_speed_mbps(iface, iftype):
- if iftype == "ethernet":
- try:
- with open(f"/sys/class/net/{iface}/speed") as f:
- return int(f.read().strip())
- except (OSError, ValueError):
- return None
- if iftype == "wifi":
- try:
- out = subprocess.check_output(
- ["iw", "dev", iface, "link"],
- text=True, stderr=subprocess.DEVNULL,
- )
- except (subprocess.CalledProcessError, FileNotFoundError):
- return None
- for line in out.splitlines():
- line = line.strip()
- if line.startswith("tx bitrate:"):
- try:
- return round(float(line.split(":", 1)[1].split()[0]))
- except (IndexError, ValueError):
- return None
- return None
-
-
-def check_net_status():
- global NET_LAST_TIME, NET_LAST_BYTES
-
- ifaces = get_all_default_ifaces()
- now = time.monotonic()
-
- if not ifaces:
- NET_LAST_TIME = now
- NET_LAST_BYTES = {}
- return {"status": "down", "tooltip": "Network Offline", "routes": []}
-
- routes = []
- any_up = False
- any_limited = False
-
- for iface in ifaces:
- oper = get_operstate(iface)
- if oper == "up":
- any_up = True
- elif oper == "dormant":
- any_limited = True
-
- iftype = get_iface_type(iface)
- ip_addr = get_iface_ip(iface)
- speed = get_speed_mbps(iface, iftype) if oper == "up" else None
-
- # Live throughput delta calculation per interface
- rx_bytes, tx_bytes = get_iface_io_bytes(iface)
- rx_str, tx_str = "0B", "0B"
-
- if oper != "down" and rx_bytes is not None and tx_bytes is not None:
- if NET_LAST_TIME is not None and iface in NET_LAST_BYTES:
- dt = now - NET_LAST_TIME
- if dt > 0:
- prev_rx, prev_tx = NET_LAST_BYTES[iface]
- rx_rate = max(0, (rx_bytes - prev_rx) / dt)
- tx_rate = max(0, (tx_bytes - prev_tx) / dt)
- rx_str = format_rate(rx_rate)
- tx_str = format_rate(tx_rate)
- NET_LAST_BYTES[iface] = (rx_bytes, tx_bytes)
- else:
- rx_str, tx_str = None, None
-
- routes.append({
- "iface": iface,
- "ip": ip_addr,
- "type": iftype,
- "speed": speed,
- "rx": rx_str,
- "tx": tx_str,
- })
-
- NET_LAST_TIME = now
-
- # Global status aggregate
- if any_up:
- overall_status = "up"
- elif any_limited:
- overall_status = "limited"
- else:
- overall_status = "down"
-
- # Build tooltip: first line with speed (rx/tx), then each iface: ip
- if overall_status == "down" or not routes:
- tooltip = "Network Offline"
- else:
- first = routes[0]
- rx = first.get("rx") or "-"
- tx = first.get("tx") or "-"
- tooltip = f"↓{rx} ↑{tx}"
- for r in routes:
- iface = r.get("iface") or "unknown"
- ip = r.get("ip") or "no ip"
- tooltip += f"\n{iface}: {ip}"
-
- return {"status": overall_status, "tooltip": tooltip, "routes": routes}
-
-
-def net_loop():
- """Event-driven listener for link state transitions."""
- proc = subprocess.Popen(
- ["inotifywait", "-m", "-e", "modify", "/run/systemd/netif/state"],
- stdout=subprocess.PIPE, text=True, bufsize=1,
- )
- for _ in proc.stdout:
- with STATE_LOCK:
- STATE["net"] = check_net_status()
- emit()
-
-
-def sound_loop(interval=1.0):
- """Poll the default audio sink for volume/mute changes."""
- while True:
- time.sleep(interval)
- with STATE_LOCK:
- STATE["sound"] = get_sound_state()
- emit()
-
-
-def emit():
- with STATE_LOCK:
- print(json.dumps(STATE), flush=True)
-
-
-def main():
- with STATE_LOCK:
- STATE["net"] = check_net_status()
- emit()
-
- threading.Thread(target=cpu_ram_loop, daemon=True).start()
- threading.Thread(target=net_loop, daemon=True).start()
- threading.Thread(target=sound_loop, daemon=True).start()
- threading.Event().wait()
-
-
-if __name__ == "__main__":
- main()
diff --git a/modules/desktop/eww/workspaces.py b/modules/desktop/eww/workspaces.py
deleted file mode 100644
index 4019a17..0000000
--- a/modules/desktop/eww/workspaces.py
+++ /dev/null
@@ -1,121 +0,0 @@
-#!/usr/bin/env python3
-"""workspaces: emit workspace list with each workspace's focused-app icon,
-driven by niri's event-stream instead of polling."""
-import json
-import subprocess
-import gi
-from gi.repository import Gio, Gtk
-gi.require_version("Gtk", "3.0")
-
-icon_theme = Gtk.IconTheme.get_default()
-_icon_cache: dict[str, str] = {}
-
-
-def try_desktop_app_info(desktop_id: str):
- try:
- return Gio.DesktopAppInfo.new(desktop_id)
- except TypeError:
- return None
-
-
-def resolve_icon_path(app_id: str) -> str:
- if not app_id:
- return ""
- if app_id in _icon_cache:
- return _icon_cache[app_id]
-
- app_info = try_desktop_app_info(f"{app_id}.desktop") \
- or try_desktop_app_info(f"{app_id.lower()}.desktop")
-
- if app_info is None:
- for info in Gio.AppInfo.get_all():
- if isinstance(info, Gio.DesktopAppInfo):
- wm_class = info.get_startup_wm_class()
- if wm_class and wm_class.lower() == app_id.lower():
- app_info = info
- break
-
- icon_path = ""
- gicon = app_info.get_icon() if app_info else None
- if gicon is not None:
- lookup = icon_theme.lookup_by_gicon(gicon, 24, Gtk.IconLookupFlags.FORCE_SIZE)
- if lookup:
- icon_path = lookup.get_filename() or ""
- if not icon_path:
- lookup = icon_theme.lookup_icon(app_id, 24, Gtk.IconLookupFlags.FORCE_SIZE)
- if lookup:
- icon_path = lookup.get_filename() or ""
-
- _icon_cache[app_id] = icon_path
- return icon_path
-
-
-def niri_json(*args):
- out = subprocess.run(["niri", "msg", "-j", *args], capture_output=True, text=True, check=True)
- return json.loads(out.stdout)
-
-
-def emit_workspaces():
- workspaces = niri_json("workspaces")
- windows = {w["id"]: w for w in niri_json("windows")}
-
- def window_sort_key(w):
- # order windows as they appear in the columns; floating windows last
- pos = (w.get("layout") or {}).get("pos_in_scrolling_layout")
- if pos is not None:
- return (0, pos[0], pos[1])
- return (1, w["id"], 0)
-
- result = []
- for ws in sorted(workspaces, key=lambda w: w["idx"]):
- active_win = windows.get(ws.get("active_window_id"))
- app_id = (active_win or {}).get("app_id") or ""
-
- ws_name = ws.get("name") or f"Workspace {ws['idx']}"
-
- ws_windows = sorted(
- (w for w in windows.values() if w.get("workspace_id") == ws["id"]),
- key=window_sort_key,
- )
- lines = [ws_name] + [
- f"{i}. {(w.get('app_id') or 'unknown')}: {w.get('title') or '(untitled)'}"
- for i, w in enumerate(ws_windows, start=1)
- ]
- tooltip = "\n".join(lines) if ws_windows else ws_name
-
- result.append({
- "idx": ws["idx"],
- "id": ws["id"],
- "is_active": ws.get("is_active", False),
- "icon": resolve_icon_path(app_id),
- "tooltip": tooltip,
- })
- print(json.dumps(result), flush=True)
-
-
-RELEVANT_EVENTS = {
- "WorkspacesChanged", "WorkspaceActivated", "WorkspaceUrgencyChanged",
- "WorkspaceActiveWindowChanged", "WindowOpenedOrChanged", "WindowClosed",
-}
-
-
-def main():
- emit_workspaces()
- proc = subprocess.Popen(
- ["niri", "msg", "-j", "event-stream"],
- stdout=subprocess.PIPE, text=True, bufsize=1,
- )
- for line in proc.stdout:
- try:
- key = next(iter(json.loads(line)))
- except (json.JSONDecodeError, StopIteration):
- continue
- if key in RELEVANT_EVENTS:
- try:
- emit_workspaces()
- except subprocess.CalledProcessError:
- pass
-
-
-if __name__ == "__main__":
- main()
diff --git a/modules/desktop/eww/yuck.nix b/modules/desktop/eww/yuck.nix
index 353c094..4f6c158 100644
--- a/modules/desktop/eww/yuck.nix
+++ b/modules/desktop/eww/yuck.nix
@@ -8,21 +8,19 @@
config = lib.mkIf config.desktop.enable {
programs.eww.yuckConfig =
let
- mkPythonScript =
+ mkLuaScript =
{
name,
path,
description ? "",
- python ? pkgs.python3,
- pythonPackages ? (ps: [ ]),
+ lua ? pkgs.luajit,
+ luaPackages ? (_: [ ]),
deps ? [ ],
gobjectIntrospection ? false,
}:
let
- pythonEnv = python.withPackages pythonPackages;
- pathPrefix = lib.concatStringsSep ":" (
- [ "${pythonEnv}/bin" ] ++ map (p: "${lib.getBin p}/bin") deps
- );
+ luaEnv = lua.withPackages luaPackages;
+ pathPrefix = lib.concatStringsSep ":" ([ "${luaEnv}/bin" ] ++ map (p: "${lib.getBin p}/bin") deps);
in
pkgs.stdenv.mkDerivation {
pname = name;
@@ -61,10 +59,16 @@
'';
};
- system-stats = mkPythonScript {
+ system-stats = mkLuaScript {
name = "eww-system-stats";
- path = ./system-stats.py;
+ path = ./system-stats.lua;
description = "Emit {cpu, ram, net} as JSON.";
+ luaPackages = (
+ ps: [
+ ps.lua-cjson
+ ps.luaposix
+ ]
+ );
deps = [
pkgs.iw
pkgs.iproute2
@@ -74,29 +78,24 @@
];
};
- workspaces = mkPythonScript {
- name = "eww-workspaces";
- path = ./workspaces.py;
- description = "Emit workspace list with each focused-app icon, driven by niri's event-stream.";
- pythonPackages = (ps: [ ps.pygobject3 ]);
- deps = [ config.desktop.niri.package ];
- gobjectIntrospection = true;
- };
-
- activate-windows = mkPythonScript {
- name = "eww-activate-windows";
- path = ./activate-windows.py;
- description = "Emit icons for windows on the active niri workspace via event-stream.";
- pythonPackages = (ps: [ ps.pygobject3 ]);
+ niri-status = mkLuaScript {
+ name = "niri-status";
+ path = ./niri-status.lua;
+ description = "Emit workspace and active window information list.";
+ luaPackages = (
+ ps: [
+ ps.lgi
+ ps.lua-cjson
+ ]
+ );
deps = [ config.desktop.niri.package ];
gobjectIntrospection = true;
};
in
builtins.replaceStrings
[
- "@@workspaces-script@@"
- "@@windows-script@@"
- "@@systemstats-script@@"
+ "@@niri-status-script@@"
+ "@@system-stats-script@@"
"@@icon-offline@@"
"@@icon-wifi@@"
"@@icon-ethernet@@"
@@ -107,8 +106,7 @@
"@@icon-calendar@@"
]
[
- (lib.getExe workspaces)
- (lib.getExe activate-windows)
+ (lib.getExe niri-status)
(lib.getExe system-stats)
(toString ../../../assets/icons/bar/offline.svg)
(toString ../../../assets/icons/bar/wifi.svg)