406 lines
14 KiB
Python
Executable File
406 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# args_json: {"type":"object","required":["action"],"properties":{"action":{"type":"string","description":"list|focus|close|minimize|maximize|move|resize|desktops|desktop"},"title":{"type":"string","description":"Window title substring"},"x":{"type":"string","description":"X position (for move)"},"y":{"type":"string","description":"Y position (for move)"},"width":{"type":"string","description":"Width (for resize)"},"height":{"type":"string","description":"Height (for resize)"},"number":{"type":"string","description":"Desktop number (for desktop)"}}}
|
|
# ollie:prompt
|
|
# ## gui_windows
|
|
#
|
|
# KWin window management via D-Bus.
|
|
#
|
|
# **Args**: `[action, ...]`
|
|
# - Actions: `list`, `focus`, `close`, `minimize`, `maximize`, `move`, `resize`, `desktops`
|
|
#
|
|
# ```
|
|
# gui_windows(action="list")
|
|
# gui_windows(action="focus", title="Firefox")
|
|
# ```
|
|
# ollie:end
|
|
"""Manage KDE windows via KWin D-Bus scripting interface.
|
|
|
|
Commands:
|
|
list - list all windows (title, class, geometry, desktop, state)
|
|
focus <title_substr> - raise and focus window matching title
|
|
close <title_substr> - close window matching title
|
|
minimize <title_substr> - minimize window
|
|
maximize <title_substr> - toggle maximize on window
|
|
move <title_substr> <x> <y> - move window to position
|
|
resize <title_substr> <w> <h> - resize window
|
|
desktops - list virtual desktops
|
|
desktop <number> - switch to virtual desktop
|
|
tile <left|right|title_substr> - tile window to half-screen
|
|
"""
|
|
import sys
|
|
import subprocess
|
|
import json
|
|
import tempfile
|
|
import os
|
|
import time
|
|
|
|
|
|
def kwin_script(js_code):
|
|
"""Execute a KWin script and return output."""
|
|
# Write script to temp file
|
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False) as f:
|
|
f.write(js_code)
|
|
script_path = f.name
|
|
|
|
try:
|
|
# Load the script
|
|
load_result = subprocess.run([
|
|
"gdbus", "call", "--session",
|
|
"--dest", "org.kde.KWin",
|
|
"--object-path", "/Scripting",
|
|
"--method", "org.kde.kwin.Scripting.loadScript",
|
|
script_path, "ollie_gui_windows"
|
|
], capture_output=True, text=True, timeout=5)
|
|
|
|
if load_result.returncode != 0:
|
|
print(f"ERROR: loadScript failed: {load_result.stderr}", file=sys.stderr)
|
|
return None
|
|
|
|
# Parse script ID from "(int32 N,)"
|
|
script_id = load_result.stdout.strip().strip("()").split(",")[0].replace("int32 ", "")
|
|
|
|
# Run the script
|
|
run_result = subprocess.run([
|
|
"gdbus", "call", "--session",
|
|
"--dest", "org.kde.KWin",
|
|
"--object-path", f"/Scripting/Script{script_id}",
|
|
"--method", "org.kde.kwin.Script.run"
|
|
], capture_output=True, text=True, timeout=5)
|
|
|
|
# Give script time to execute (async)
|
|
time.sleep(0.3)
|
|
|
|
# Read output from KWin's journal/stdout - scripts use print()
|
|
# Unfortunately KWin script output goes to systemd journal, not easily capturable.
|
|
# For listing we'll use wmctrl/qdbus instead.
|
|
|
|
# Unload
|
|
subprocess.run([
|
|
"gdbus", "call", "--session",
|
|
"--dest", "org.kde.KWin",
|
|
"--object-path", "/Scripting",
|
|
"--method", "org.kde.kwin.Scripting.unloadScript",
|
|
"ollie_gui_windows"
|
|
], capture_output=True, text=True, timeout=5)
|
|
|
|
return True
|
|
finally:
|
|
os.unlink(script_path)
|
|
|
|
|
|
def list_windows_wmctrl():
|
|
"""List windows using wmctrl (works on X11 and XWayland)."""
|
|
try:
|
|
result = subprocess.run(
|
|
["wmctrl", "-l", "-G", "-p"],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
if result.returncode == 0:
|
|
return result.stdout
|
|
except FileNotFoundError:
|
|
pass
|
|
return None
|
|
|
|
|
|
def list_windows_qdbus():
|
|
"""List windows using KWin's D-Bus interface for Wayland."""
|
|
# Use KWin scripting to dump window list to a known file
|
|
out_file = "/tmp/ollie_winlist.txt"
|
|
js = f"""
|
|
var clients = workspace.windowList();
|
|
var lines = [];
|
|
for (var i = 0; i < clients.length; i++) {{
|
|
var c = clients[i];
|
|
if (c.skipTaskbar || c.specialWindow) continue;
|
|
var geo = c.frameGeometry;
|
|
var state = [];
|
|
if (c.minimized) state.push('minimized');
|
|
if (c.maximized) state.push('maximized');
|
|
if (c.fullScreen) state.push('fullscreen');
|
|
if (c.active) state.push('active');
|
|
lines.push(
|
|
c.caption + '\\t' +
|
|
c.resourceClass + '\\t' +
|
|
geo.x + ',' + geo.y + ',' + geo.width + ',' + geo.height + '\\t' +
|
|
'desktop:' + c.desktop + '\\t' +
|
|
state.join(',')
|
|
);
|
|
}}
|
|
callDBus('org.ollie.SessionManager', '/org/ollie/SessionManager',
|
|
'org.ollie.SessionManager', 'SetConfig',
|
|
'winlist', lines.join('\\n'));
|
|
"""
|
|
# Alternative: write to file via script. But KWin scripts are sandboxed.
|
|
# Use qdbus to get window list if available
|
|
try:
|
|
result = subprocess.run(
|
|
["qdbus6", "org.kde.KWin", "/KWin", "org.kde.KWin.queryWindowInfo"],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
if result.returncode == 0:
|
|
return result.stdout
|
|
except FileNotFoundError:
|
|
try:
|
|
result = subprocess.run(
|
|
["qdbus", "org.kde.KWin", "/KWin", "org.kde.KWin.queryWindowInfo"],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
if result.returncode == 0:
|
|
return result.stdout
|
|
except FileNotFoundError:
|
|
pass
|
|
return None
|
|
|
|
|
|
def list_windows():
|
|
"""List windows using best available method."""
|
|
# Try wmctrl first (most parseable)
|
|
result = list_windows_wmctrl()
|
|
if result:
|
|
print("ID\tDESKTOP\tPID\tX\tY\tW\tH\tTITLE")
|
|
for line in result.strip().split("\n"):
|
|
parts = line.split(None, 7)
|
|
if len(parts) >= 8:
|
|
wid, desktop, pid, x, y, w, h = parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6]
|
|
title = parts[7] if len(parts) > 7 else ""
|
|
# Strip hostname prefix from title
|
|
if " " in title:
|
|
title = title.split(" ", 1)[1] if title.split(" ", 1)[0].isalpha() else title
|
|
print(f"{wid}\t{desktop}\t{pid}\t{x}\t{y}\t{w}\t{h}\t{title}")
|
|
return
|
|
|
|
# Try xdotool
|
|
try:
|
|
result = subprocess.run(
|
|
["xdotool", "search", "--onlyvisible", "--name", ""],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
print("ID\tTITLE")
|
|
for wid in result.stdout.strip().split("\n"):
|
|
name_result = subprocess.run(
|
|
["xdotool", "getwindowname", wid],
|
|
capture_output=True, text=True, timeout=2
|
|
)
|
|
name = name_result.stdout.strip() if name_result.returncode == 0 else "?"
|
|
print(f"{wid}\t{name}")
|
|
return
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
print("ERROR: no window listing tool available (install wmctrl or xdotool)", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def find_window(title_substr):
|
|
"""Find window ID by title substring."""
|
|
try:
|
|
result = subprocess.run(
|
|
["wmctrl", "-l"],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
if result.returncode == 0:
|
|
for line in result.stdout.strip().split("\n"):
|
|
if title_substr.lower() in line.lower():
|
|
return line.split()[0]
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
["xdotool", "search", "--name", title_substr],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
return result.stdout.strip().split("\n")[0]
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
return None
|
|
|
|
|
|
def focus_window(title_substr):
|
|
wid = find_window(title_substr)
|
|
if not wid:
|
|
print(f"ERROR: no window matching '{title_substr}'", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
subprocess.run(["wmctrl", "-i", "-a", wid], check=True, timeout=5)
|
|
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
try:
|
|
subprocess.run(["xdotool", "windowactivate", wid], check=True, timeout=5)
|
|
except (FileNotFoundError, subprocess.CalledProcessError) as e:
|
|
print(f"ERROR: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
print(f"focused: {wid}")
|
|
|
|
|
|
def close_window(title_substr):
|
|
wid = find_window(title_substr)
|
|
if not wid:
|
|
print(f"ERROR: no window matching '{title_substr}'", file=sys.stderr)
|
|
sys.exit(1)
|
|
try:
|
|
subprocess.run(["wmctrl", "-i", "-c", wid], check=True, timeout=5)
|
|
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
try:
|
|
subprocess.run(["xdotool", "windowclose", wid], check=True, timeout=5)
|
|
except (FileNotFoundError, subprocess.CalledProcessError) as e:
|
|
print(f"ERROR: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
print(f"closed: {wid}")
|
|
|
|
|
|
def minimize_window(title_substr):
|
|
wid = find_window(title_substr)
|
|
if not wid:
|
|
print(f"ERROR: no window matching '{title_substr}'", file=sys.stderr)
|
|
sys.exit(1)
|
|
try:
|
|
subprocess.run(["xdotool", "windowminimize", wid], check=True, timeout=5)
|
|
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
# KWin script fallback
|
|
kwin_script(f"""
|
|
var clients = workspace.windowList();
|
|
for (var i = 0; i < clients.length; i++) {{
|
|
if (clients[i].caption.toLowerCase().includes('{title_substr.lower()}')) {{
|
|
clients[i].minimized = true;
|
|
break;
|
|
}}
|
|
}}
|
|
""")
|
|
print(f"minimized: {wid}")
|
|
|
|
|
|
def maximize_window(title_substr):
|
|
# KWin script is the clean way
|
|
kwin_script(f"""
|
|
var clients = workspace.windowList();
|
|
for (var i = 0; i < clients.length; i++) {{
|
|
if (clients[i].caption.toLowerCase().includes('{title_substr.lower()}')) {{
|
|
clients[i].setMaximize(!clients[i].maximized, !clients[i].maximized);
|
|
break;
|
|
}}
|
|
}}
|
|
""")
|
|
print(f"toggled maximize: {title_substr}")
|
|
|
|
|
|
def move_window(title_substr, x, y):
|
|
wid = find_window(title_substr)
|
|
if not wid:
|
|
print(f"ERROR: no window matching '{title_substr}'", file=sys.stderr)
|
|
sys.exit(1)
|
|
try:
|
|
subprocess.run(["wmctrl", "-i", "-r", wid, "-e", f"0,{x},{y},-1,-1"],
|
|
check=True, timeout=5)
|
|
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
try:
|
|
subprocess.run(["xdotool", "windowmove", wid, str(x), str(y)],
|
|
check=True, timeout=5)
|
|
except (FileNotFoundError, subprocess.CalledProcessError) as e:
|
|
print(f"ERROR: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
print(f"moved {wid} to {x},{y}")
|
|
|
|
|
|
def resize_window(title_substr, w, h):
|
|
wid = find_window(title_substr)
|
|
if not wid:
|
|
print(f"ERROR: no window matching '{title_substr}'", file=sys.stderr)
|
|
sys.exit(1)
|
|
try:
|
|
subprocess.run(["wmctrl", "-i", "-r", wid, "-e", f"0,-1,-1,{w},{h}"],
|
|
check=True, timeout=5)
|
|
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
try:
|
|
subprocess.run(["xdotool", "windowsize", wid, str(w), str(h)],
|
|
check=True, timeout=5)
|
|
except (FileNotFoundError, subprocess.CalledProcessError) as e:
|
|
print(f"ERROR: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
print(f"resized {wid} to {w}x{h}")
|
|
|
|
|
|
def list_desktops():
|
|
try:
|
|
result = subprocess.run(
|
|
["wmctrl", "-d"],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
if result.returncode == 0:
|
|
print(result.stdout.rstrip())
|
|
return
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
# Fallback via KWin D-Bus
|
|
try:
|
|
result = subprocess.run([
|
|
"gdbus", "call", "--session",
|
|
"--dest", "org.kde.KWin",
|
|
"--object-path", "/VirtualDesktopManager",
|
|
"--method", "org.freedesktop.DBus.Properties.Get",
|
|
"org.kde.KWin.VirtualDesktopManager", "desktops"
|
|
], capture_output=True, text=True, timeout=5)
|
|
if result.returncode == 0:
|
|
print(result.stdout.rstrip())
|
|
return
|
|
except FileNotFoundError:
|
|
pass
|
|
print("ERROR: cannot list desktops", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def switch_desktop(num):
|
|
try:
|
|
subprocess.run(["wmctrl", "-s", str(int(num) - 1)], check=True, timeout=5)
|
|
print(f"switched to desktop {num}")
|
|
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
try:
|
|
subprocess.run([
|
|
"gdbus", "call", "--session",
|
|
"--dest", "org.kde.KWin",
|
|
"--object-path", "/VirtualDesktopManager",
|
|
"--method", "org.kde.KWin.VirtualDesktopManager.setCurrent",
|
|
str(num)
|
|
], check=True, capture_output=True, timeout=5)
|
|
print(f"switched to desktop {num}")
|
|
except Exception as e:
|
|
print(f"ERROR: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def main():
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from _lib.args import parse_args as _parse
|
|
args = _parse()
|
|
cmd = args.require("action")
|
|
|
|
if cmd == "list":
|
|
list_windows()
|
|
elif cmd == "focus":
|
|
focus_window(args.require("title"))
|
|
elif cmd == "close":
|
|
close_window(args.require("title"))
|
|
elif cmd == "minimize":
|
|
minimize_window(args.require("title"))
|
|
elif cmd == "maximize":
|
|
maximize_window(args.require("title"))
|
|
elif cmd == "move":
|
|
move_window(args.require("title"), args.require("x"), args.require("y"))
|
|
elif cmd == "resize":
|
|
resize_window(args.require("title"), args.require("width"), args.require("height"))
|
|
elif cmd == "desktops":
|
|
list_desktops()
|
|
elif cmd == "desktop":
|
|
switch_desktop(args.require("number"))
|
|
else:
|
|
print(f"unknown command: {cmd}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|