Files
niri_overview/niri_overview.py

377 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# niri_overview.py
import sys
import os
import json
import subprocess
import signal
import tempfile
import fcntl
from collections import defaultdict
# --- Single-instance lock ---
LOCKFILE = os.path.join(tempfile.gettempdir(), "niri_overview.lock")
_lock_fp = open(LOCKFILE, "w")
try:
fcntl.flock(_lock_fp, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
sys.stderr.write("Error: Another instance of niri_overview is already running.\n")
sys.exit(1)
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
gi.require_version("Pango", "1.0")
gi.require_version("Gio", "2.0")
gi.require_version("GLib", "2.0")
from gi.repository import Gtk, Gdk, Pango, Gio, GLib
import config
# --- Globals ---
monitor_windows = {}
niri_event_process = None
update_pending = False
ICON_SIZE_PX = config.ICON_SIZE_PX
FALLBACK_GLYPHS = config.FALLBACK_GLYPHS
DEFAULT_GLYPH = config.DEFAULT_GLYPH
WINDOW_WIDTH = config.WINDOW_WIDTH
CSS_PATH = config.STYLE_CSS_PATH
# --- Run niri commands ---
def run_niri_command(args, capture_output=True, check=True, timeout=2):
cmd = ["niri", "msg"] + args
try:
res = subprocess.run(cmd,
capture_output=capture_output,
text=True,
check=check,
timeout=timeout)
if capture_output and "--json" in args:
return json.loads(res.stdout) if res.stdout.strip() else []
return True
except Exception as e:
print(f"niri msg error {' '.join(args)}: {e}", file=sys.stderr)
return None
# --- Main window ---
class NiriMonitorWindow(Gtk.Window):
def __init__(self, output_name, output_data):
super().__init__(title=f"Niri Overview {output_name}")
self.output_name = output_name
self.output_info = output_data.get("info", {})
self.workspaces = output_data.get("workspaces", [])
self.window_list = output_data.get("window_list", [])
# window setup
self.set_type_hint(Gdk.WindowTypeHint.DOCK)
self.set_keep_above(True)
self.set_decorated(False)
self.set_default_size(WINDOW_WIDTH, 250)
self.set_resizable(True)
self.set_can_focus(True)
self.grab_focus()
# position
pos = self.output_info.get("logical", {})
self.move(pos.get("x", 0), pos.get("y", 0))
# RGBA support
screen = self.get_screen()
visual = screen.get_rgba_visual() if screen else None
if visual and screen.is_composited():
self.set_visual(visual)
self.set_app_paintable(True)
self.connect("draw", self.on_draw)
# layout
self.main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
self.main_vbox.set_border_width(5)
self.add(self.main_vbox)
sc = Gtk.ScrolledWindow()
sc.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
sc.set_hexpand(True)
sc.set_vexpand(True)
self.main_vbox.pack_start(sc, True, True, 0)
self.content_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
sc.add(self.content_vbox)
self.apply_css()
self.rebuild_content()
self.connect("delete-event", lambda *a: False)
self.connect("destroy", self.on_destroy)
self.connect("key-press-event", self.on_key_press)
def apply_css(self):
provider = Gtk.CssProvider()
provider.load_from_path(CSS_PATH)
screen = self.get_screen()
Gtk.StyleContext.add_provider_for_screen(
screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER
)
def on_draw(self, widget, cr):
rgba = Gdk.RGBA()
rgba.parse(config.BACKGROUND_RGBA)
alloc = self.get_allocation()
cr.set_source_rgba(rgba.red, rgba.green, rgba.blue, rgba.alpha)
cr.rectangle(0, 0, alloc.width, alloc.height)
cr.fill()
return False
def on_destroy(self, widget):
monitor_windows.pop(self.output_name, None)
if not monitor_windows:
GLib.idle_add(quit_app)
def on_key_press(self, widget, event):
key = Gdk.keyval_name(event.keyval)
if key in ("q", "Escape"):
GLib.idle_add(quit_app)
return True
return False
def on_workspace_click(self, widget, event):
if event.button == 1:
run_niri_command(
["action", "focus-workspace", str(widget.workspace_index)],
capture_output=False
)
GLib.idle_add(quit_app)
return True
return False
def on_app_click(self, widget, event):
if event.button == 1:
run_niri_command(
["action", "focus-window", "--id", str(widget.window_id)],
capture_output=False
)
GLib.idle_add(quit_app)
return True
return False
def rebuild_content(self):
for child in self.content_vbox.get_children():
self.content_vbox.remove(child)
# output title
lbl = Gtk.Label(label=f"Output: {self.output_name}")
lbl.set_xalign(0.0)
lbl.get_style_context().add_class("output-title")
self.content_vbox.pack_start(lbl, False, False, 0)
ws_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
self.content_vbox.pack_start(ws_box, False, False, 0)
by_ws = defaultdict(list)
for w in self.window_list:
by_ws[w.get("workspace_id")].append(w)
for ws in sorted(self.workspaces, key=lambda w: w.get("idx",0)):
eb = Gtk.EventBox()
eb.get_style_context().add_class("clickable")
eb.get_style_context().add_class("workspace")
if ws.get("is_focused"):
eb.get_style_context().add_class("workspace-focused")
eb.workspace_index = ws["idx"]
eb.connect("button-press-event", self.on_workspace_click)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
eb.add(vbox)
title = f"Ws {ws['idx']}"
if ws.get("name"):
title += f" ({ws['name']})"
if ws.get("is_active"):
title += ""
t = Gtk.Label(label=title)
t.set_xalign(0.0)
t.get_style_context().add_class("ws-title")
vbox.pack_start(t, False, False, 0)
apps = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
vbox.pack_start(apps, False, False, 0)
wins = by_ws.get(ws["id"], [])
if not wins:
empty = Gtk.Label(label="(empty)")
empty.set_halign(Gtk.Align.CENTER)
apps.pack_start(empty, True, True, 0)
else:
for w in sorted(wins, key=lambda x: x.get("id",0)):
aeb = Gtk.EventBox()
aeb.get_style_context().add_class("clickable")
aeb.get_style_context().add_class("app-container")
if w.get("is_focused"):
aeb.get_style_context().add_class("window-focused")
aeb.window_id = w["id"]
aeb.connect("button-press-event", self.on_app_click)
vb = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
aeb.add(vb)
# icon or glyph
ib = Gtk.Box()
ib.set_size_request(ICON_SIZE_PX, ICON_SIZE_PX)
for lookup in (
w.get("app_id","").lower(),
f"{w.get('app_id')}.desktop",
w.get("app_id","").split(".")[-1]
):
if Gtk.IconTheme.get_default().has_icon(lookup):
icon = Gio.ThemedIcon.new(lookup)
img = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.LARGE_TOOLBAR)
img.set_pixel_size(ICON_SIZE_PX)
ib.pack_start(img, True, True, 0)
break
else:
glyph = FALLBACK_GLYPHS.get(w.get("app_id","").lower(), DEFAULT_GLYPH)
lab = Gtk.Label(label=glyph)
lab.get_style_context().add_class("glyph-label")
ib.pack_start(lab, True, True, 0)
vb.pack_start(ib, False, False, 0)
nm = Gtk.Label(label=w.get("title", w.get("app_id","")))
nm.set_ellipsize(Pango.EllipsizeMode.END)
nm.get_style_context().add_class("app-label")
vb.pack_start(nm, True, False, 0)
apps.pack_start(aeb, False, False, 0)
ws_box.pack_start(eb, False, False, 0)
self.content_vbox.show_all()
# --- Data fetch & UI refresh ---
def fetch_and_process_data():
outs = run_niri_command(["--json", "outputs"])
if outs is None:
return None
wss = run_niri_command(["--json", "workspaces"]) or []
wins = run_niri_command(["--json", "windows"]) or []
data = {}
for name, info in (outs.items() if isinstance(outs,dict) else []):
data[name] = {"info": {"name": name, **info}, "workspaces": [], "window_list": []}
ws_map = {}
for ws in wss:
out = ws.get("output")
if out in data:
data[out]["workspaces"].append(ws)
ws_map[ws.get("id")] = out
for w in wins:
out = ws_map.get(w.get("workspace_id"))
if out in data:
data[out]["window_list"].append(w)
return data
def refresh_ui_from_niri():
global monitor_windows, update_pending
update_pending = False
new = fetch_and_process_data()
if new is None:
if not monitor_windows and (not niri_event_process or niri_event_process.poll() is not None):
GLib.idle_add(quit_app)
return False
# remove stale
for out in set(monitor_windows) - set(new):
monitor_windows[out].destroy()
# update/create
for out, dat in new.items():
if out in monitor_windows:
w = monitor_windows[out]
w.output_info = dat["info"]
w.workspaces = dat["workspaces"]
w.window_list = dat["window_list"]
w.rebuild_content()
else:
w = NiriMonitorWindow(out, dat)
monitor_windows[out] = w
w.show_all()
if not monitor_windows and (not niri_event_process or niri_event_process.poll() is not None):
GLib.idle_add(quit_app)
return False
# --- Event stream ---
def read_niri_event_callback(src, cond):
global update_pending, niri_event_process
if cond & GLib.IO_HUP:
niri_event_process = None
if not update_pending:
update_pending = True
GLib.idle_add(refresh_ui_from_niri)
return False
if cond & GLib.IO_IN and niri_event_process:
line = niri_event_process.stdout.readline()
if line.strip():
try:
e = json.loads(line)
t = next(iter(e), None)
if t in {
"WorkspacesChanged","WindowsChanged","WindowOpenedOrChanged",
"WindowClosed","WorkspaceActivated","WorkspaceActiveWindowChanged",
"WindowFocusChanged","OutputAdded","OutputRemoved","OutputChanged"
} and not update_pending:
update_pending = True
GLib.idle_add(refresh_ui_from_niri)
except:
pass
return True
return False
def start_event_stream():
global niri_event_process
try:
niri_event_process = subprocess.Popen(
["niri","msg","--json","event-stream"],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, bufsize=1
)
GLib.io_add_watch(
niri_event_process.stdout.fileno(),
GLib.IO_IN | GLib.IO_HUP,
read_niri_event_callback
)
except FileNotFoundError:
print("Error: 'niri' not found.", file=sys.stderr)
GLib.idle_add(quit_app)
# --- Quit & signal ---
def quit_app():
global niri_event_process
if niri_event_process and niri_event_process.poll() is None:
niri_event_process.terminate()
for w in list(monitor_windows.values()):
w.destroy()
if Gtk.main_level() > 0:
Gtk.main_quit()
def signal_handler(sig, frame):
GLib.idle_add(quit_app)
# --- Main ---
if __name__ == "__main__":
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
start_event_stream()
refresh_ui_from_niri()
Gtk.main()