205 lines
7.0 KiB
Python
Executable File
205 lines
7.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""kanshi-autoconfig - generiert kanshi-Profile aus live erkannten Outputs.
|
|
|
|
Liest die angeschlossenen Monitore via `wlr-randr --json` aus (EDID-Namen,
|
|
bevorzugte Modi, aktuelle Skalierung) und schreibt daraus eine
|
|
funktionsfaehige ~/.config/kanshi/config mit den Profilen
|
|
docked / undocked / fallback.
|
|
|
|
Verhalten:
|
|
--print Vorschlag auf stdout, schreibt nichts.
|
|
--apply Schreibt (mit Backup *.bak), fragt nicht.
|
|
--apply-if-managed Schreibt nur, wenn die Config fehlt oder den Marker
|
|
'# kanshi-autoconfig: managed' enthaelt.
|
|
-> Hand-Edits bleiben unangetastet. Wird vom
|
|
labwc-Autostart bei jedem Login aufgerufen.
|
|
|
|
Faellt die Erkennung fehl (kein Wayland, kein wlr-randr), wird eine
|
|
reine Fallback-Config geschrieben, damit nie ein schwarzer Bildschirm
|
|
ohne Layout dasteht.
|
|
"""
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
MARKER = "# kanshi-autoconfig: managed"
|
|
CONFIG_PATH = Path.home() / ".config" / "kanshi" / "config"
|
|
|
|
|
|
def detect_outputs():
|
|
"""Liste von Dicts: name, internal, width, height, rate, scale."""
|
|
try:
|
|
proc = subprocess.run(
|
|
["wlr-randr", "--json"], capture_output=True, text=True, timeout=10
|
|
)
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
return []
|
|
if proc.returncode != 0:
|
|
return []
|
|
try:
|
|
data = json.loads(proc.stdout)
|
|
except json.JSONDecodeError:
|
|
return []
|
|
outputs = []
|
|
for out in data:
|
|
name = out.get("name", "?")
|
|
# Nur verbundene Outputs betrachten (kanshi matcht nur angesteckte)
|
|
if out.get("connected") is False:
|
|
continue
|
|
modes = out.get("modes", []) or []
|
|
preferred = next((m for m in modes if m.get("preferred")), None)
|
|
current = out.get("current_mode") or out.get("mode")
|
|
chosen = preferred or current or (modes[0] if modes else None)
|
|
if not chosen:
|
|
continue
|
|
width = chosen.get("width")
|
|
height = chosen.get("height")
|
|
rate = chosen.get("refresh") or chosen.get("rate")
|
|
if isinstance(rate, (int, float)) and rate > 1000:
|
|
rate = rate / 1000 # mHz -> Hz
|
|
outputs.append(
|
|
{
|
|
"name": name,
|
|
"internal": name.startswith(("eDP-", "LVDS-", "DSI-")),
|
|
"width": width,
|
|
"height": height,
|
|
"rate": rate,
|
|
"scale": out.get("scale", 1) or 1,
|
|
}
|
|
)
|
|
return outputs
|
|
|
|
|
|
def mode_str(o):
|
|
s = f"{o['width']}x{o['height']}"
|
|
if o["rate"]:
|
|
s += f"@{o['rate']:.2f}" if isinstance(o["rate"], float) else f"@{o['rate']}"
|
|
return s
|
|
|
|
|
|
def render(outputs):
|
|
lines = [
|
|
MARKER,
|
|
f"# generiert am {datetime.now().isoformat(timespec='seconds')}",
|
|
"# Quelle: wlr-randr (live). Feintuning: wdisplays oder Modus direkt",
|
|
"# in dieser Datei anpassen - bei unveraendertem Output-Set bleibt",
|
|
"# die Datei (inkl. deiner Modus-Tweaks) bei jedem Login erhalten.",
|
|
"# Neu generiert wird nur bei geaendertem Output-Set (Docking) oder",
|
|
"# per --apply. Marker-Zeile loeschen = ganz hand-gepflegt.",
|
|
"",
|
|
]
|
|
externals = [o for o in outputs if not o["internal"]]
|
|
internals = [o for o in outputs if o["internal"]]
|
|
|
|
if externals:
|
|
lines.append("profile docked {")
|
|
for i in internals:
|
|
lines.append(f" output {i['name']} disable")
|
|
x = 0
|
|
for o in externals:
|
|
lines.append(
|
|
f" output {o['name']} mode {mode_str(o)} position {x},0 scale {o['scale']}"
|
|
)
|
|
x += o["width"] or 0
|
|
lines.append("}")
|
|
lines.append("")
|
|
|
|
if internals:
|
|
lines.append("profile undocked {")
|
|
for o in internals:
|
|
lines.append(
|
|
f" output {o['name']} mode {mode_str(o)} position 0,0 scale {o['scale']}"
|
|
)
|
|
lines.append("}")
|
|
lines.append("")
|
|
elif not externals:
|
|
# Gar nichts erkannt -> reiner Fallback, nie leer lassen
|
|
lines.append("# Keine Outputs erkannt (ausserhalb einer Wayland-Session")
|
|
lines.append("# aufgerufen?) - nur Fallback geschrieben.")
|
|
lines.append("")
|
|
|
|
lines.append("profile fallback {")
|
|
lines.append(" output * enable")
|
|
lines.append("}")
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def is_managed(path: Path):
|
|
if not path.exists():
|
|
return True
|
|
try:
|
|
return MARKER in path.read_text()
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def apply(text):
|
|
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
if CONFIG_PATH.exists():
|
|
backup = CONFIG_PATH.with_suffix(".bak")
|
|
shutil.copy2(CONFIG_PATH, backup)
|
|
print(f"Backup: {backup}")
|
|
CONFIG_PATH.write_text(text)
|
|
print(f"Geschrieben: {CONFIG_PATH}")
|
|
|
|
|
|
def config_output_names(path: Path):
|
|
"""Output-Namen aus einer bestehenden kanshi-Config (ohne '*' Fallback)."""
|
|
names = set()
|
|
try:
|
|
for line in path.read_text().splitlines():
|
|
parts = line.strip().split()
|
|
if len(parts) >= 2 and parts[0] == "output" and parts[1] != "*":
|
|
names.add(parts[1].strip("'\""))
|
|
except OSError:
|
|
pass
|
|
return names
|
|
|
|
|
|
def main(argv):
|
|
outputs = detect_outputs()
|
|
if not outputs:
|
|
print("WARN: keine Outputs erkannt", file=sys.stderr)
|
|
text = render(outputs)
|
|
# Default ohne Schreib-Flag: nur anzeigen, nichts schreiben (sicher).
|
|
if "--print" in argv or ("--apply" not in argv and "--apply-if-managed" not in argv):
|
|
if not outputs:
|
|
print("# (Fallback-Anteil, da keine Outputs erkannt)", file=sys.stderr)
|
|
print(text)
|
|
if outputs:
|
|
print(
|
|
f"# Erkannt: {', '.join(o['name'] for o in outputs)}",
|
|
file=sys.stderr,
|
|
)
|
|
return 0
|
|
if "--apply-if-managed" in argv:
|
|
if not CONFIG_PATH.exists():
|
|
apply(text) # Erst-Login: anlegen
|
|
return 0
|
|
if not is_managed(CONFIG_PATH):
|
|
print(f"{CONFIG_PATH} ist hand-gepflegt - lasse sie unangetastet.")
|
|
return 0
|
|
if not outputs:
|
|
# Erkennung fehlgeschlagen -> niemals eine gute Config clobbern.
|
|
print("WARN: Erkennung fehlgeschlagen - lasse verwaltete Config unangetastet.",
|
|
file=sys.stderr)
|
|
return 0
|
|
if {o["name"] for o in outputs} == config_output_names(CONFIG_PATH):
|
|
# Gleiches Output-Set (z.B. jeden Boot dieselbe VM-Anzeige):
|
|
# Modus-Tweaks des Users bleiben erhalten, kein Rewrite.
|
|
print(f"Outputs unveraendert ({', '.join(sorted(config_output_names(CONFIG_PATH)))})"
|
|
" - behalte Config (inkl. Modus-Tweaks) bei.")
|
|
return 0
|
|
# Output-Set geaendert (angedockt/abgedockt): neu generieren.
|
|
apply(text)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|