Upload files to "/"

This commit is contained in:
2025-04-25 13:50:06 +02:00
parent 9c313a964c
commit 41c30f5353
4 changed files with 585 additions and 0 deletions

40
config.py Normal file
View File

@@ -0,0 +1,40 @@
# config.py
import os
BASE_DIR = os.path.dirname(__file__)
STYLE_CSS_PATH = os.path.join(BASE_DIR, "style.css")
ICON_SIZE_PX = 24
WINDOW_WIDTH = 350
# Darker and more transparent overlay
BACKGROUND_RGBA = "rgba(0, 0, 0, 0.6)"
WORKSPACE_FOCUS_BORDER = "2px solid #F5C2E7"
WORKSPACE_FOCUS_BOX_SHADOW = "0 0 6px rgba(245, 194, 231, 0.8)"
FALLBACK_GLYPHS = {
"niri-overview": "",
"niri-overview.py": "",
"niri_overview": "",
"niri_overview.py": "",
"niri_overview.bin": "",
"foot": "",
"firefox": "",
"chromium": "",
"google-chrome": "",
"terminal": "",
"code": "",
"vim": "",
"nvim": "",
"emacs": "",
"files": "",
"nautilus": "",
"thunar": "",
"dolphin": "",
"unknown": ""
}
DEFAULT_GLYPH = ""

376
niri_overview.py Normal file
View File

@@ -0,0 +1,376 @@
#!/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()

81
readme.md Normal file
View File

@@ -0,0 +1,81 @@
dependencies
gtk3.0 +++
python
build binary
```sh
python -m nuitka --standalone --onefile --include-package=gi --include-plugin-directory=/usr/lib/python3.13/site-packages/gi --include-data-file=style.css=style.css niri_overview.py
```
## Overview
This repository provides a GTK3-based overlay that presents Niri outputs, workspaces and windows in a compact, semi-transparent panel. It enables rapid navigation by clicking workspace or application entries to focus them, and visually distinguishes the active workspace and focused window through configurable neon-tinted borders and shadows.
## Dependencies
The application requires Python 3 (version 3.8 or later) and the following PyGObject-based GTK libraries:
`PyGObject` with GTK 3.0, Gdk 3.0, Pango 1.0, Gio 2.0 and GLib 2.0 bindings
• A working `niri` command-line client in your $PATH, emitting JSON via `niri msg --json …`
• A compositor supporting RGBA visuals (e.g. picom or Mutter) for transparency
• A Nerd Font (e.g. Hack Nerd Font) installed to render glyph fallbacks
On Debian/Ubuntu-derived systems these can be installed via:
```bash
sudo apt install python3-gi python3-gi-cairo gir1.2-gtk-3.0 gir1.2-gdkpixbuf-2.0 \
gir1.2-pango-1.0 gir1.2-glib-2.0 gir1.2-gio-2.0
```
Ensure `niri` is available (often bundled with Wayfire or similar) and that your compositor is running.
## Installation
Place the three files—`niri_overview.py`, `config.py` and `style.css`—in a single directory. Make `niri_overview.py` executable:
```bash
chmod +x niri_overview.py
```
Confirm that `config.py` references the correct path to `style.css` (by default it assumes both reside in the same folder).
to use nuitka to compile a static binary (on arch)
```bash
python -m nuitka --standalone --onefile --include-package=gi --include-plugin-directory=/usr/lib/python3.13/site-packages/gi --include-data-file=style.css=style.css niri_overview.py
```
## Configuration
All tunable parameters appear in `config.py`. Adjust `ICON_SIZE_PX` or `WINDOW_WIDTH` for icon sizing or panel width. Modify `BACKGROUND_RGBA` to change the overlays darkness and transparency. Application glyph fallbacks and the default glyph for unknown apps can also be extended there.
Styling is defined in `style.css`. You may alter the background RGBA, neon accent colors or border/shadow rules. GTK3s CSS parser does not support custom properties, so all color values must be specified literally.
## Usage
Launch the overview panel by running:
```bash
./niri_overview.py
```
The script will subscribe to Niris JSON event stream, perform an initial data fetch, then enter the GTK main loop. To exit, press `q`, `Escape`, or close all visible panels.
## Customization
To use a different font for glyph fallbacks, install and reference it in your system. To change neon accent hues, edit the corresponding color values in `style.css` under the sections:
- `.output-title` for the outputname accent
- `.clickable.workspace-focused` and `.clickable.window-focused` for focused borders
- `.glyph-label` for fallback glyph coloring
After modifying `style.css`, simply restart `niri_overview.py` to see changes.
## License
This project is provided under the GPL V3 License.

88
style.css Normal file
View File

@@ -0,0 +1,88 @@
/* style.css — greyer dark mode, muted neon accents */
window {
background-color: rgba(40, 40, 40, 0.6);
color: #CCCCCC;
}
.clickable {
border-radius: 6px;
transition: border-color 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease;
}
/* Titles */
.output-title {
font-weight: bold;
font-size: large;
padding-left: 5px;
margin-bottom: 6px;
color: #60CFA8; /* subdued neon mint */
}
/* Workspace containers */
.clickable.workspace {
background-color: rgba(50, 50, 50, 0.5);
border: 1px solid rgba(7, 181, 176, 0.6); /* softer turquoise */
margin: 4px 0;
padding: 6px;
}
.clickable.workspace:hover {
border: 1px solid #20D2A0; /* peppermint accent */
box-shadow: 0 0 8px rgba(32, 210, 160, 0.4);
}
.clickable.workspace-focused {
border: 2px solid #07B5B0; /* muted neon turquoise */
box-shadow: 0 0 8px rgba(7, 181, 176, 0.5);
}
.ws-title {
font-weight: bold;
font-size: small;
padding-left: 2px;
margin-bottom: 4px;
color: #4FAF8F; /* alt subdued mint */
}
/* Application icons/containers */
.clickable.app-container {
background-color: rgba(50, 50, 50, 0.4);
border: 1px solid rgba(7, 181, 176, 0.6);
margin: 2px;
padding: 4px;
}
.clickable.app-container:hover {
background-color: rgba(50, 50, 50, 0.6);
border: 1px solid #20D2A0;
box-shadow: 0 0 6px rgba(32, 210, 160, 0.4);
}
.clickable.window-focused {
border: 2px solid #60CFA8; /* subdued neon mint */
box-shadow: 0 0 6px rgba(96, 207, 168, 0.5);
}
.clickable.window-focused label {
color: #07B5B0; /* muted neon turquoise */
font-weight: bold;
}
.glyph-label {
font-size: 24px;
color: #20D2A0; /* peppermint accent */
}
.app-label {
font-size: x-small;
color: #BBBBBB;
padding-top: 2px;
}
.clickable:hover {
background-color: rgba(50, 50, 50, 0.7);
}