diff --git a/Makefile b/Makefile index 6b27836..512ed67 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ BUILD_DIR := build-cmake .PHONY: all clean run install uninstall \ kf5 kf5-install kf5-uninstall \ krunner-install dolphin-install plasmoid-install plasmoid-uninstall \ - tray data + tray data tools-install tools-uninstall all: build data @@ -186,6 +186,29 @@ data: cp -rf tools/_lib $(HOME)/.config/ollie/tools/_lib cp -rf tools/* $(HOME)/.config/ollie/tools +# --- GUI Tools (standalone install) --- + +GUI_TOOLS := gui_accessibility gui_activities gui_apps gui_clipboard \ + gui_files gui_input gui_notify gui_screenshot \ + gui_shortcuts gui_windows + +tools-install: + @echo "=== Installing GUI automation tools ===" + mkdir -p $(HOME)/.config/ollie/tools + @for t in $(GUI_TOOLS); do \ + install -m755 tools/$$t $(HOME)/.config/ollie/tools/$$t; \ + echo " installed: $$t"; \ + done + @echo "=== Done. Tools available in next ollie session. ===" + +tools-uninstall: + @echo "=== Removing GUI automation tools ===" + @for t in $(GUI_TOOLS); do \ + rm -f $(HOME)/.config/ollie/tools/$$t; \ + echo " removed: $$t"; \ + done + @echo "=== Done ===" + # --- Development helpers --- tray: build diff --git a/prompts/tools-gui.md b/prompts/tools-gui.md new file mode 100644 index 0000000..fad46a0 --- /dev/null +++ b/prompts/tools-gui.md @@ -0,0 +1,213 @@ +# GUI Automation Tools + +KDE desktop automation via D-Bus and accessibility interfaces. All tools use `call_tool`. + +**Dependencies**: `gdbus` (always), `wmctrl`/`xdotool` (window ops), `ydotool` (Wayland input), `spectacle` (screenshots), `python3-gobject` + AT-SPI2 typelib (accessibility). + +--- + +## gui_clipboard + +Read/write the KDE clipboard via Klipper. + +**Args**: `[command]` or `[command, value]` + +Commands: `get`, `set`, `history`, `clear` + +``` +call_tool: calls=[{tool: "gui_clipboard", args: ["get"]}] +call_tool: calls=[{tool: "gui_clipboard", args: ["set", "hello world"]}] +call_tool: calls=[{tool: "gui_clipboard", args: ["history", "2"]}] +``` + +--- + +## gui_notify + +Show desktop notifications with optional interactive action buttons. Waits for user response when actions are specified. + +**Args**: `[summary]` or `[summary, body, --flags...]` + +Flags: `--actions=label1,label2,...`, `--timeout=ms`, `--icon=name`, `--urgency=low|normal|critical` + +``` +call_tool: calls=[{tool: "gui_notify", args: ["Build complete", "All tests passed"]}] +call_tool: calls=[{tool: "gui_notify", args: ["Deploy?", "Ready to push to prod", "--actions=Yes,No", "--urgency=critical"]}] +``` + +**Returns**: For action notifications, prints the chosen label (e.g., `Yes`) or `dismissed`/`timeout`. + +--- + +## gui_screenshot + +Capture screenshots. Uses spectacle (KDE) with fallbacks to ImageMagick. + +**Args**: `[mode]` with optional flags + +Modes: `active` (default), `screen`, `area`, `window` + +Flags: `--output=path`, `--screen=N`, `--title=substr` + +``` +call_tool: calls=[{tool: "gui_screenshot", args: ["active"]}] +call_tool: calls=[{tool: "gui_screenshot", args: ["screen", "--output=/tmp/shot.png"]}] +call_tool: calls=[{tool: "gui_screenshot", args: ["window", "--title=Firefox"]}] +``` + +**Returns**: output file path and size. + +--- + +## gui_windows + +Manage windows: list, focus, close, move, resize, virtual desktops. + +**Args**: `[command, ...params]` + +Commands: +- `list` — all windows with geometry and state +- `focus ` — raise and focus +- `close <title>` — close window +- `minimize <title>` / `maximize <title>` +- `move <title> <x> <y>` — reposition +- `resize <title> <w> <h>` — resize +- `desktops` — list virtual desktops +- `desktop <n>` — switch to desktop N + +``` +call_tool: calls=[{tool: "gui_windows", args: ["list"]}] +call_tool: calls=[{tool: "gui_windows", args: ["focus", "Kate"]}] +call_tool: calls=[{tool: "gui_windows", args: ["move", "Terminal", "0", "0"]}] +call_tool: calls=[{tool: "gui_windows", args: ["resize", "Firefox", "1920", "1080"]}] +call_tool: calls=[{tool: "gui_windows", args: ["desktop", "2"]}] +``` + +Title matching is case-insensitive substring. + +--- + +## gui_apps + +Launch, list, or activate applications. + +**Args**: `[command]` or `[command, app_name]` + +Commands: `list` (running), `installed` (all .desktop), `launch <name>`, `activate <title_or_class>` + +``` +call_tool: calls=[{tool: "gui_apps", args: ["launch", "firefox"]}] +call_tool: calls=[{tool: "gui_apps", args: ["activate", "dolphin"]}] +call_tool: calls=[{tool: "gui_apps", args: ["installed"]}] +``` + +--- + +## gui_input + +Simulate keyboard and mouse input. Uses ydotool (Wayland) or xdotool (X11). + +**Args**: `[command, ...params]` + +Commands: +- `key <combo>` — press key combo (e.g., `ctrl+c`, `Return`, `alt+F4`, `super`) +- `type <text>` — type a string character by character +- `click [button] [x y]` — mouse click (1=left, 2=middle, 3=right) +- `move <x> <y> [--relative]` — move cursor +- `scroll <up|down|left|right> [amount]` + +``` +call_tool: calls=[{tool: "gui_input", args: ["key", "ctrl+s"]}] +call_tool: calls=[{tool: "gui_input", args: ["type", "Hello, world!"]}] +call_tool: calls=[{tool: "gui_input", args: ["click", "1", "500", "300"]}] +call_tool: calls=[{tool: "gui_input", args: ["scroll", "down", "5"]}] +``` + +**Caution**: Input simulation is a write operation — confirm intent before using `key` with destructive combos (alt+F4, ctrl+w) or `click` on unknown targets. + +--- + +## gui_activities + +Manage KDE Plasma Activities. + +**Args**: `[command]` or `[command, name_or_id]` + +Commands: `list`, `current`, `switch <name_or_id>`, `create <name>`, `remove <name_or_id>` + +``` +call_tool: calls=[{tool: "gui_activities", args: ["list"]}] +call_tool: calls=[{tool: "gui_activities", args: ["switch", "Development"]}] +call_tool: calls=[{tool: "gui_activities", args: ["create", "Research"]}] +``` + +--- + +## gui_files + +Search files via Baloo (KDE file indexer). Falls back to `locate`. + +**Args**: `[query, ...flags]` + +Flags: `--type=document|image|video|audio|folder`, `--path=dir`, `--limit=N` + +``` +call_tool: calls=[{tool: "gui_files", args: ["meeting notes"]}] +call_tool: calls=[{tool: "gui_files", args: ["invoice", "--type=document", "--path=/home/lkn/doc"]}] +call_tool: calls=[{tool: "gui_files", args: ["screenshot", "--type=image", "--limit=10"]}] +``` + +--- + +## gui_shortcuts + +Manage and trigger KDE global keyboard shortcuts. + +**Args**: `[command, ...params]` + +Commands: +- `list` — all shortcut components +- `list <component>` — shortcuts for that component +- `get <component> <name>` — current key binding +- `trigger <component> <name>` — invoke the shortcut action +- `set <component> <name> <keys>` — change key binding + +``` +call_tool: calls=[{tool: "gui_shortcuts", args: ["list"]}] +call_tool: calls=[{tool: "gui_shortcuts", args: ["list", "kwin"]}] +call_tool: calls=[{tool: "gui_shortcuts", args: ["trigger", "kwin", "Overview"]}] +call_tool: calls=[{tool: "gui_shortcuts", args: ["trigger", "plasmashell", "show dashboard"]}] +``` + +--- + +## gui_accessibility + +Full GUI automation via the AT-SPI2 accessibility tree. Inspect, navigate, and interact with any application's UI elements. + +**Args**: `[command, app_name, ...params]` + +Commands: +- `apps` — list all accessible applications (no app_name needed) +- `tree <app> [depth]` — dump widget tree (default depth: 4) +- `inspect <app> [depth]` — brief overview (default depth: 2) +- `find <app> <role> [name]` — find elements by role and optional name +- `read <app> <path>` — read text/value of element +- `click <app> <path>` — activate/click element +- `type <app> <path> <text>` — type into element +- `action <app> <path> <action_name>` — invoke named action + +Paths use slash-separated indices (`0/2/1`) or `role:name` patterns (`push button:OK`). + +``` +call_tool: calls=[{tool: "gui_accessibility", args: ["apps"]}] +call_tool: calls=[{tool: "gui_accessibility", args: ["inspect", "Kate"]}] +call_tool: calls=[{tool: "gui_accessibility", args: ["find", "Firefox", "push button", "Reload"]}] +call_tool: calls=[{tool: "gui_accessibility", args: ["click", "Firefox", "push button:Reload"]}] +call_tool: calls=[{tool: "gui_accessibility", args: ["type", "Kate", "0/0/text:untitled", "Hello world"]}] +call_tool: calls=[{tool: "gui_accessibility", args: ["tree", "Dolphin", "3"]}] +``` + +**Workflow**: Use `apps` → `inspect` → `find` to locate elements, then `click`/`type`/`action` to interact. + +**Requires**: `python3-gobject` and AT-SPI2 typelib (`dev-libs/at-spi2-core` on Gentoo). diff --git a/tools/gui_accessibility b/tools/gui_accessibility new file mode 100755 index 0000000..f9919f0 --- /dev/null +++ b/tools/gui_accessibility @@ -0,0 +1,464 @@ +#!/usr/bin/env python3 +# description: GUI automation via AT-SPI2 accessibility tree. Args: <inspect|find|click|type|read|tree|action> [app] [query...] +"""Interact with any GUI application through the accessibility (AT-SPI2) interface. + +This uses the D-Bus-based accessibility tree to inspect, navigate, and interact +with UI elements in any application that supports AT-SPI (virtually all GTK, Qt, +and Electron apps). + +Commands: + tree <app> - dump full widget tree + inspect <app> [depth] - brief overview of top-level widgets + find <app> <role> [name] - find elements by role and optional name + read <app> <path> - read text/value of element + click <app> <path> - click/activate element + type <app> <path> <text> - type text into element + action <app> <path> <action_name> - invoke named action on element + apps - list accessible applications + +Paths are slash-separated indices (e.g., "0/2/1" = first child, third child, second child) +or you can use "role:name" patterns (e.g., "push button:OK"). + +Requires: python3 with gi (PyGObject) and Atspi typelib, OR the 'accerciser' package. +""" +import sys +import os +import time + + +def check_deps(): + """Check for AT-SPI2 dependencies.""" + try: + import gi + gi.require_version('Atspi', '2.0') + from gi.repository import Atspi + return True + except (ImportError, ValueError): + return False + + +def get_atspi(): + import gi + gi.require_version('Atspi', '2.0') + from gi.repository import Atspi + return Atspi + + +def list_apps(): + """List all accessible applications.""" + Atspi = get_atspi() + desktop = Atspi.get_desktop(0) + count = desktop.get_child_count() + print(f"{'INDEX':<6} {'NAME':<30} {'CHILDREN'}") + for i in range(count): + app = desktop.get_child_at_index(i) + if app: + name = app.get_name() or "(unnamed)" + children = app.get_child_count() + print(f"{i:<6} {name:<30} {children}") + + +def find_app(name_substr): + """Find application by name substring.""" + Atspi = get_atspi() + desktop = Atspi.get_desktop(0) + count = desktop.get_child_count() + for i in range(count): + app = desktop.get_child_at_index(i) + if app: + app_name = app.get_name() or "" + if name_substr.lower() in app_name.lower(): + return app + return None + + +def role_name(accessible): + """Get human-readable role name.""" + Atspi = get_atspi() + try: + role = accessible.get_role() + return accessible.get_role_name() + except Exception: + return "unknown" + + +def get_states(accessible): + """Get state set as list of strings.""" + Atspi = get_atspi() + try: + state_set = accessible.get_state_set() + states = [] + for s in dir(Atspi.StateType): + if s.startswith('_'): + continue + try: + st = getattr(Atspi.StateType, s) + if state_set.contains(st): + states.append(s.lower()) + except Exception: + pass + return states + except Exception: + return [] + + +def resolve_path(root, path_str): + """Resolve a path string to an accessible element. + + Path can be: + - Numeric indices: "0/2/1" + - Role:name pattern: "push button:OK" + - Mixed: "0/frame:Main/push button:OK" + """ + if not path_str: + return root + + parts = path_str.split('/') + current = root + + for part in parts: + if ':' in part: + # Role:name pattern + target_role, target_name = part.split(':', 1) + found = False + count = current.get_child_count() + for i in range(count): + child = current.get_child_at_index(i) + if child: + r = role_name(child) + n = child.get_name() or "" + if (target_role.lower() in r.lower() and + target_name.lower() in n.lower()): + current = child + found = True + break + if not found: + print(f"ERROR: no child matching '{part}' under {role_name(current)}:{current.get_name()}", + file=sys.stderr) + sys.exit(1) + else: + # Numeric index + try: + idx = int(part) + child = current.get_child_at_index(idx) + if not child: + print(f"ERROR: no child at index {idx}", file=sys.stderr) + sys.exit(1) + current = child + except ValueError: + # Try as name match + found = False + count = current.get_child_count() + for i in range(count): + child = current.get_child_at_index(i) + if child and part.lower() in (child.get_name() or "").lower(): + current = child + found = True + break + if not found: + print(f"ERROR: no child matching '{part}'", file=sys.stderr) + sys.exit(1) + + return current + + +def dump_tree(accessible, indent=0, max_depth=4, path=""): + """Dump accessibility tree.""" + if indent > max_depth: + return + + name = accessible.get_name() or "" + role = role_name(accessible) + children = accessible.get_child_count() + + # Get text content if available + text = "" + try: + ti = accessible.get_text() + if ti: + text_content = ti.get_text(0, min(ti.get_character_count(), 50)) + if text_content and text_content != name: + text = f' text="{text_content}"' + except Exception: + pass + + # Get value if available + value = "" + try: + vi = accessible.get_value() + if vi: + value = f' value={vi.get_current_value()}' + except Exception: + pass + + prefix = " " * indent + path_display = path or "/" + + # States of interest + states = get_states(accessible) + state_flags = "" + if "focused" in states: + state_flags += " [focused]" + if "checked" in states: + state_flags += " [checked]" + if "selected" in states: + state_flags += " [selected]" + if not ("visible" in states and "showing" in states): + state_flags += " [hidden]" + + print(f"{prefix}[{path_display}] {role}: \"{name}\"{text}{value}{state_flags} ({children} children)") + + for i in range(children): + child = accessible.get_child_at_index(i) + if child: + child_path = f"{path}/{i}" if path else str(i) + dump_tree(child, indent + 1, max_depth, child_path) + + +def find_elements(accessible, target_role, target_name="", results=None, path=""): + """Find elements matching role and optional name.""" + if results is None: + results = [] + + r = role_name(accessible) + n = accessible.get_name() or "" + + role_match = target_role.lower() in r.lower() if target_role else True + name_match = target_name.lower() in n.lower() if target_name else True + + if role_match and name_match: + results.append((path or "/", r, n, accessible)) + + count = accessible.get_child_count() + for i in range(min(count, 200)): # cap to avoid infinite trees + child = accessible.get_child_at_index(i) + if child: + child_path = f"{path}/{i}" if path else str(i) + find_elements(child, target_role, target_name, results, child_path) + + return results + + +def read_element(accessible): + """Read text/value from element.""" + # Try text interface + try: + ti = accessible.get_text() + if ti: + count = ti.get_character_count() + if count > 0: + text = ti.get_text(0, count) + print(text) + return + except Exception: + pass + + # Try value interface + try: + vi = accessible.get_value() + if vi: + print(vi.get_current_value()) + return + except Exception: + pass + + # Fall back to name + name = accessible.get_name() + if name: + print(name) + else: + print("(no readable content)") + + +def click_element(accessible): + """Activate/click an element.""" + Atspi = get_atspi() + + # Try action interface + try: + ai = accessible.get_action() + if ai: + n_actions = ai.get_n_actions() + for i in range(n_actions): + action_name = ai.get_action_name(i) + if action_name in ('click', 'activate', 'press', 'invoke'): + ai.do_action(i) + print(f"performed: {action_name}") + return + # If no preferred action found, do the first one + if n_actions > 0: + action_name = ai.get_action_name(0) + ai.do_action(0) + print(f"performed: {action_name}") + return + except Exception as e: + pass + + # Try generating a click event via component interface + try: + ci = accessible.get_component() + if ci: + pos = ci.get_position(Atspi.CoordType.SCREEN) + size = ci.get_size() + # Click center + x = pos.x + size.x // 2 + y = pos.y + size.y // 2 + Atspi.generate_mouse_event(x, y, 'b1c') + print(f"clicked at: {x},{y}") + return + except Exception: + pass + + print("ERROR: no way to click this element", file=sys.stderr) + sys.exit(1) + + +def type_text(accessible, text): + """Type text into an element.""" + Atspi = get_atspi() + + # Try editable text interface + try: + ei = accessible.get_editable_text() + if ei: + # Clear existing and insert + ti = accessible.get_text() + if ti: + count = ti.get_character_count() + if count > 0: + ei.delete_text(0, count) + ei.insert_text(0, text, len(text)) + print(f"typed: {len(text)} chars") + return + except Exception: + pass + + # Fallback: focus and use keyboard events + try: + ci = accessible.get_component() + if ci: + ci.grab_focus() + time.sleep(0.1) + for ch in text: + Atspi.generate_keyboard_event(0, ch, Atspi.KeySynthType.STRING) + time.sleep(0.01) + print(f"typed via keyboard: {len(text)} chars") + return + except Exception: + pass + + print("ERROR: cannot type into this element", file=sys.stderr) + sys.exit(1) + + +def do_action(accessible, action_name): + """Invoke a named action on element.""" + try: + ai = accessible.get_action() + if ai: + n_actions = ai.get_n_actions() + for i in range(n_actions): + if ai.get_action_name(i) == action_name: + ai.do_action(i) + print(f"performed: {action_name}") + return + # List available actions + available = [ai.get_action_name(i) for i in range(n_actions)] + print(f"ERROR: action '{action_name}' not found. Available: {available}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"ERROR: {e}", file=sys.stderr) + sys.exit(1) + + +def main(): + if not check_deps(): + print("ERROR: AT-SPI2 dependencies not available", file=sys.stderr) + print("Install: pip install PyGObject (or system package python3-gobject)", file=sys.stderr) + print("Also need: gir1.2-atspi-2.0 (Debian/Ubuntu) or gobject-introspection with Atspi typelib", file=sys.stderr) + sys.exit(1) + + if len(sys.argv) < 2: + print("usage: gui_accessibility <apps|tree|inspect|find|read|click|type|action> [app] [args...]", file=sys.stderr) + sys.exit(1) + + cmd = sys.argv[1] + + if cmd == "apps": + list_apps() + return + + if len(sys.argv) < 3: + print(f"usage: gui_accessibility {cmd} <app_name> [args...]", file=sys.stderr) + sys.exit(1) + + app_name = sys.argv[2] + app = find_app(app_name) + if not app: + print(f"ERROR: no accessible application matching '{app_name}'", file=sys.stderr) + print("Tip: run 'gui_accessibility apps' to see available applications", file=sys.stderr) + sys.exit(1) + + if cmd == "tree": + depth = int(sys.argv[3]) if len(sys.argv) > 3 else 4 + dump_tree(app, max_depth=depth) + + elif cmd == "inspect": + depth = int(sys.argv[3]) if len(sys.argv) > 3 else 2 + dump_tree(app, max_depth=depth) + + elif cmd == "find": + if len(sys.argv) < 4: + print("usage: gui_accessibility find <app> <role> [name]", file=sys.stderr) + sys.exit(1) + target_role = sys.argv[3] + target_name = sys.argv[4] if len(sys.argv) > 4 else "" + results = find_elements(app, target_role, target_name) + if not results: + print(f"no elements found matching role='{target_role}' name='{target_name}'") + else: + print(f"{'PATH':<20} {'ROLE':<20} {'NAME'}") + for path, role, name, _ in results[:50]: + print(f"{path:<20} {role:<20} {name}") + + elif cmd == "read": + if len(sys.argv) < 4: + print("usage: gui_accessibility read <app> <path>", file=sys.stderr) + sys.exit(1) + path = sys.argv[3] + element = resolve_path(app, path) + read_element(element) + + elif cmd == "click": + if len(sys.argv) < 4: + print("usage: gui_accessibility click <app> <path>", file=sys.stderr) + sys.exit(1) + path = sys.argv[3] + element = resolve_path(app, path) + click_element(element) + + elif cmd == "type": + if len(sys.argv) < 5: + print("usage: gui_accessibility type <app> <path> <text>", file=sys.stderr) + sys.exit(1) + path = sys.argv[3] + text = sys.argv[4] + element = resolve_path(app, path) + type_text(element, text) + + elif cmd == "action": + if len(sys.argv) < 5: + print("usage: gui_accessibility action <app> <path> <action_name>", file=sys.stderr) + sys.exit(1) + path = sys.argv[3] + action_name = sys.argv[4] + element = resolve_path(app, path) + do_action(element, action_name) + + else: + print(f"unknown command: {cmd}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tools/gui_activities b/tools/gui_activities new file mode 100755 index 0000000..3f21310 --- /dev/null +++ b/tools/gui_activities @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# description: Manage KDE Plasma Activities via D-Bus. Args: <list|current|switch|create|remove> [name_or_id] +set -euo pipefail + +SERVICE="org.kde.ActivityManager" +OBJ_PATH="/ActivityManager/Activities" +IFACE="org.kde.ActivityManager.Activities" + +cmd="${1:-list}" + +case "$cmd" in + list) + # List all activities with their names and states + ids=$(gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.ListActivities" 2>/dev/null | \ + sed "s/^(\[//;s/\],)$//;s/'//g;s/, /\n/g") + + if [ -z "$ids" ]; then + echo "ERROR: cannot list activities (ActivityManager not running?)" >&2 + exit 1 + fi + + current=$(gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.CurrentActivity" 2>/dev/null | \ + sed "s/^('//;s/',)$//") + + echo "ID STATE NAME" + while IFS= read -r id; do + [ -z "$id" ] && continue + name=$(gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.ActivityName" "$id" 2>/dev/null | \ + sed "s/^('//;s/',)$//") + if [ "$id" = "$current" ]; then + state="active" + else + state="stopped" + fi + echo "$id $state $name" + done <<< "$ids" + ;; + current) + id=$(gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.CurrentActivity" 2>/dev/null | \ + sed "s/^('//;s/',)$//") + name=$(gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.ActivityName" "$id" 2>/dev/null | \ + sed "s/^('//;s/',)$//") + echo "$id $name" + ;; + switch) + target="${2:?usage: gui_activities switch <name_or_id>}" + + # Try as ID first + result=$(gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.SetCurrentActivity" "$target" 2>/dev/null) && { + echo "switched to: $target" + exit 0 + } + + # Search by name + ids=$(gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.ListActivities" 2>/dev/null | \ + sed "s/^(\[//;s/\],)$//;s/'//g;s/, /\n/g") + + while IFS= read -r id; do + [ -z "$id" ] && continue + name=$(gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.ActivityName" "$id" 2>/dev/null | \ + sed "s/^('//;s/',)$//") + if echo "$name" | grep -qi "$target"; then + gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.SetCurrentActivity" "$id" >/dev/null + echo "switched to: $name ($id)" + exit 0 + fi + done <<< "$ids" + + echo "ERROR: no activity matching '$target'" >&2 + exit 1 + ;; + create) + name="${2:?usage: gui_activities create <name>}" + id=$(gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.AddActivity" "$name" 2>/dev/null | \ + sed "s/^('//;s/',)$//") + echo "created: $id ($name)" + ;; + remove) + target="${2:?usage: gui_activities remove <name_or_id>}" + + # Try as ID directly + gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.RemoveActivity" "$target" 2>/dev/null && { + echo "removed: $target" + exit 0 + } + + # Search by name + ids=$(gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.ListActivities" 2>/dev/null | \ + sed "s/^(\[//;s/\],)$//;s/'//g;s/, /\n/g") + + while IFS= read -r id; do + [ -z "$id" ] && continue + name=$(gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.ActivityName" "$id" 2>/dev/null | \ + sed "s/^('//;s/',)$//") + if echo "$name" | grep -qi "$target"; then + gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.RemoveActivity" "$id" >/dev/null + echo "removed: $name ($id)" + exit 0 + fi + done <<< "$ids" + + echo "ERROR: no activity matching '$target'" >&2 + exit 1 + ;; + *) + echo "usage: gui_activities <list|current|switch|create|remove> [name_or_id]" >&2 + exit 1 + ;; +esac diff --git a/tools/gui_apps b/tools/gui_apps new file mode 100755 index 0000000..590f0b6 --- /dev/null +++ b/tools/gui_apps @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# description: Launch/list/activate applications via D-Bus and KService. Args: <list|launch|activate> [app_name_or_desktop_file] +set -euo pipefail + +cmd="${1:-list}" + +case "$cmd" in + list) + # List running applications via KWin + if command -v wmctrl &>/dev/null; then + echo "CLASS TITLE" + wmctrl -l -x | while read -r wid desktop class host title; do + echo "$class $title" + done + elif command -v xdotool &>/dev/null; then + echo "WID NAME" + xdotool search --onlyvisible --name '' 2>/dev/null | while read -r wid; do + name=$(xdotool getwindowname "$wid" 2>/dev/null || echo '?') + echo "$wid $name" + done + else + # D-Bus fallback: list org.freedesktop.DBus registered names + echo "ERROR: install wmctrl or xdotool for window listing" >&2 + exit 1 + fi + ;; + launch) + app="${2:?usage: gui_apps launch <app_name_or_desktop_file>}" + + # Try gtk-launch first (handles .desktop files) + if command -v gtk-launch &>/dev/null; then + gtk-launch "$app" 2>/dev/null && { echo "launched: $app"; exit 0; } + fi + + # Try kioclient + if command -v kioclient5 &>/dev/null || command -v kioclient &>/dev/null; then + kio=$(command -v kioclient5 || command -v kioclient) + "$kio" exec "applications:$app.desktop" 2>/dev/null && { echo "launched: $app"; exit 0; } + fi + + # Try gio + if command -v gio &>/dev/null; then + # Find .desktop file + desktop_file=$(find /usr/share/applications ~/.local/share/applications \ + -name "*${app}*.desktop" 2>/dev/null | head -1) + if [ -n "$desktop_file" ]; then + gio launch "$desktop_file" && { echo "launched: $app"; exit 0; } + fi + fi + + # Try D-Bus activation + gdbus call --session \ + --dest org.freedesktop.DBus \ + --object-path /org/freedesktop/DBus \ + --method org.freedesktop.DBus.StartServiceByName \ + "$app" 0 2>/dev/null && { echo "activated: $app"; exit 0; } + + # Last resort: just exec it + if command -v "$app" &>/dev/null; then + setsid "$app" &>/dev/null & + echo "launched: $app (pid $!)" + else + echo "ERROR: cannot find or launch '$app'" >&2 + exit 1 + fi + ;; + activate) + app="${2:?usage: gui_apps activate <title_or_class>}" + # Focus existing window + if command -v wmctrl &>/dev/null; then + # Try by class first + wid=$(wmctrl -l -x | grep -i "$app" | head -1 | awk '{print $1}') + if [ -n "$wid" ]; then + wmctrl -i -a "$wid" + echo "activated: $wid" + exit 0 + fi + fi + if command -v xdotool &>/dev/null; then + wid=$(xdotool search --name "$app" 2>/dev/null | head -1) + if [ -n "$wid" ]; then + xdotool windowactivate "$wid" + echo "activated: $wid" + exit 0 + fi + fi + echo "ERROR: no window matching '$app'" >&2 + exit 1 + ;; + installed) + # List installed .desktop files + find /usr/share/applications ~/.local/share/applications \ + -name '*.desktop' 2>/dev/null | \ + xargs grep -l '^Name=' | \ + while read -r f; do + name=$(grep '^Name=' "$f" | head -1 | cut -d= -f2) + echo "$(basename "$f" .desktop) $name" + done | sort + ;; + *) + echo "usage: gui_apps <list|launch|activate|installed> [app]" >&2 + exit 1 + ;; +esac diff --git a/tools/gui_clipboard b/tools/gui_clipboard new file mode 100755 index 0000000..fd59789 --- /dev/null +++ b/tools/gui_clipboard @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# ollie:parallel read +# description: Read/write KDE clipboard via Klipper D-Bus. Args: [get|set|history] [text|index] +set -euo pipefail + +SERVICE="org.kde.klipper" +PATH_OBJ="/klipper" +IFACE="org.kde.klipper.klipper" + +cmd="${1:-get}" + +case "$cmd" in + get) + gdbus call --session --dest "$SERVICE" \ + --object-path "$PATH_OBJ" \ + --method "$IFACE.getClipboardContents" | \ + sed "s/^('//;s/')$//;s/\\\\n/\n/g" + ;; + set) + text="${2:?usage: gui_clipboard set <text>}" + gdbus call --session --dest "$SERVICE" \ + --object-path "$PATH_OBJ" \ + --method "$IFACE.setClipboardContents" "$text" >/dev/null + echo "OK: clipboard set" + ;; + history) + index="${2:-0}" + gdbus call --session --dest "$SERVICE" \ + --object-path "$PATH_OBJ" \ + --method "$IFACE.getClipboardHistoryItem" "$index" | \ + sed "s/^('//;s/')$//;s/\\\\n/\n/g" + ;; + clear) + gdbus call --session --dest "$SERVICE" \ + --object-path "$PATH_OBJ" \ + --method "$IFACE.clearClipboardHistory" >/dev/null + echo "OK: clipboard history cleared" + ;; + *) + echo "usage: gui_clipboard [get|set|history|clear] [text|index]" >&2 + exit 1 + ;; +esac diff --git a/tools/gui_files b/tools/gui_files new file mode 100755 index 0000000..e67870e --- /dev/null +++ b/tools/gui_files @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# ollie:parallel read +# description: Search files using Baloo (KDE file indexer). Args: <query> [--type=document|image|video|audio] [--path=dir] [--limit=n] +set -euo pipefail + +query="${1:?usage: gui_files <query> [--type=TYPE] [--path=DIR] [--limit=N]}" +shift + +# Parse flags +file_type="" +path_filter="" +limit="50" + +while [ $# -gt 0 ]; do + case "$1" in + --type=*) file_type="${1#--type=}" ;; + --path=*) path_filter="${1#--path=}" ;; + --limit=*) limit="${1#--limit=}" ;; + *) ;; + esac + shift +done + +# Build baloosearch command +cmd=(baloosearch) + +# Add type filter +case "$file_type" in + document|doc) cmd+=(-t Document) ;; + image|img) cmd+=(-t Image) ;; + video|vid) cmd+=(-t Video) ;; + audio|snd) cmd+=(-t Audio) ;; + folder|dir) cmd+=(-t Folder) ;; + "") + ;; + *) + cmd+=(-t "$file_type") + ;; +esac + +# Add path filter +if [ -n "$path_filter" ]; then + cmd+=(-d "$path_filter") +fi + +# Add limit +cmd+=(-l "$limit") + +# Add query +cmd+=("$query") + +# Try baloosearch first +if command -v baloosearch &>/dev/null; then + "${cmd[@]}" 2>/dev/null + exit $? +fi + +# Fallback: try baloosearch6 +if command -v baloosearch6 &>/dev/null; then + cmd[0]="baloosearch6" + "${cmd[@]}" 2>/dev/null + exit $? +fi + +# Fallback: D-Bus query to Baloo +if gdbus introspect --session --dest org.kde.baloo --object-path /org/kde/baloo &>/dev/null 2>&1; then + result=$(gdbus call --session \ + --dest org.kde.baloo \ + --object-path /org/kde/baloo \ + --method org.kde.baloo.main.search "$query" "$limit" 2>/dev/null) + echo "$result" | sed "s/^(\[//;s/\],)$//;s/'//g;s/, /\n/g" + exit 0 +fi + +# Last fallback: locate +if command -v locate &>/dev/null; then + echo "# (using locate fallback - results may be stale)" >&2 + locate -i "$query" | head -n "$limit" + exit 0 +fi + +echo "ERROR: Baloo not available and no fallback found (install baloo-utils or mlocate)" >&2 +exit 1 diff --git a/tools/gui_input b/tools/gui_input new file mode 100755 index 0000000..b19a723 --- /dev/null +++ b/tools/gui_input @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# description: Synthetic keyboard/mouse input via ydotool (Wayland) or xdotool (X11). Args: <key|type|click|move|scroll> [args...] +set -euo pipefail + +cmd="${1:?usage: gui_input <key|type|click|move|scroll> [args...]}" +shift + +# Detect input method: prefer ydotool (Wayland-safe), fall back to xdotool +if command -v ydotool &>/dev/null; then + INPUT_TOOL="ydotool" +elif command -v xdotool &>/dev/null; then + INPUT_TOOL="xdotool" +else + echo "ERROR: install ydotool (Wayland) or xdotool (X11)" >&2 + exit 1 +fi + +case "$cmd" in + key) + # Simulate key press. Examples: "Return", "ctrl+c", "alt+F4", "super" + keys="${1:?usage: gui_input key <key_combo>}" + if [ "$INPUT_TOOL" = "ydotool" ]; then + # ydotool uses key codes; map common names + ydotool key "$keys" + else + xdotool key "$keys" + fi + echo "key: $keys" + ;; + type) + # Type a string + text="${1:?usage: gui_input type <text>}" + if [ "$INPUT_TOOL" = "ydotool" ]; then + ydotool type -- "$text" + else + xdotool type --clearmodifiers -- "$text" + fi + echo "typed: ${#text} chars" + ;; + click) + # Mouse click. Args: [button] [x y] + button="${1:-1}" # 1=left, 2=middle, 3=right + if [ $# -ge 3 ]; then + x="$2"; y="$3" + if [ "$INPUT_TOOL" = "ydotool" ]; then + ydotool mousemove --absolute "$x" "$y" + sleep 0.05 + ydotool click "$button" + else + xdotool mousemove "$x" "$y" + sleep 0.05 + xdotool click "$button" + fi + echo "click: button=$button at $x,$y" + else + if [ "$INPUT_TOOL" = "ydotool" ]; then + ydotool click "$button" + else + xdotool click "$button" + fi + echo "click: button=$button at cursor" + fi + ;; + move) + # Move mouse. Args: <x> <y> [--relative] + x="${1:?usage: gui_input move <x> <y> [--relative]}" + y="${2:?usage: gui_input move <x> <y> [--relative]}" + relative="${3:-}" + if [ "$INPUT_TOOL" = "ydotool" ]; then + if [ "$relative" = "--relative" ]; then + ydotool mousemove -- "$x" "$y" + else + ydotool mousemove --absolute "$x" "$y" + fi + else + if [ "$relative" = "--relative" ]; then + xdotool mousemove_relative "$x" "$y" + else + xdotool mousemove "$x" "$y" + fi + fi + echo "moved: $x,$y${relative:+ (relative)}" + ;; + scroll) + # Scroll. Args: <up|down|left|right> [amount] + direction="${1:?usage: gui_input scroll <up|down|left|right> [amount]}" + amount="${2:-3}" + if [ "$INPUT_TOOL" = "ydotool" ]; then + case "$direction" in + up) ydotool mousemove --wheel -- 0 "-$amount" ;; + down) ydotool mousemove --wheel -- 0 "$amount" ;; + left) ydotool mousemove --wheel -- "-$amount" 0 ;; + right) ydotool mousemove --wheel -- "$amount" 0 ;; + esac + else + case "$direction" in + up) xdotool click --repeat "$amount" 4 ;; + down) xdotool click --repeat "$amount" 5 ;; + left) xdotool click --repeat "$amount" 6 ;; + right) xdotool click --repeat "$amount" 7 ;; + esac + fi + echo "scroll: $direction $amount" + ;; + *) + echo "usage: gui_input <key|type|click|move|scroll> [args...]" >&2 + echo "" >&2 + echo "Commands:" >&2 + echo " key <combo> - press key combo (e.g. ctrl+c, Return, alt+F4)" >&2 + echo " type <text> - type a string" >&2 + echo " click [btn] [x y] - mouse click (1=left 2=mid 3=right)" >&2 + echo " move <x> <y> [--relative] - move cursor" >&2 + echo " scroll <dir> [amount] - scroll (up/down/left/right)" >&2 + exit 1 + ;; +esac diff --git a/tools/gui_notify b/tools/gui_notify new file mode 100755 index 0000000..7b83a29 --- /dev/null +++ b/tools/gui_notify @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# description: Show desktop notifications with optional actions. Args: <summary> [body] [--actions=label1,label2,...] [--timeout=ms] [--icon=name] +"""Send interactive notifications via org.freedesktop.Notifications D-Bus interface. + +If --actions are specified, waits for user response and prints chosen action label. +Timeout defaults to 10000ms (10s) for action notifications, 5000ms otherwise. +""" +import sys +import argparse +import subprocess +import json + +def parse_args(): + p = argparse.ArgumentParser(description="Desktop notification with optional actions") + p.add_argument("summary", help="Notification title") + p.add_argument("body", nargs="?", default="", help="Notification body text") + p.add_argument("--actions", default="", help="Comma-separated action labels") + p.add_argument("--timeout", type=int, default=0, help="Timeout in ms (0=default)") + p.add_argument("--icon", default="ollie", help="Icon name (default: ollie)") + p.add_argument("--urgency", choices=["low", "normal", "critical"], default="normal") + return p.parse_args() + +def main(): + args = parse_args() + + # Build actions array: ["id1", "label1", "id2", "label2", ...] + actions_list = [] + if args.actions: + labels = [a.strip() for a in args.actions.split(",") if a.strip()] + for i, label in enumerate(labels): + actions_list.append(str(i)) + actions_list.append(label) + + timeout = args.timeout + if timeout == 0: + timeout = 10000 if actions_list else 5000 + + urgency_map = {"low": 0, "normal": 1, "critical": 2} + + # Build gdbus call + # Notify(app_name, replaces_id, icon, summary, body, actions, hints, timeout) + actions_str = "[" + ", ".join(f"'{a}'" for a in actions_list) + "]" + hints_str = "{'urgency': <byte %d>}" % urgency_map[args.urgency] + + cmd = [ + "gdbus", "call", "--session", + "--dest", "org.freedesktop.Notifications", + "--object-path", "/org/freedesktop/Notifications", + "--method", "org.freedesktop.Notifications.Notify", + "ollie", # app_name + "0", # replaces_id (uint32) + args.icon, # icon + args.summary, # summary + args.body, # body + actions_str, # actions + hints_str, # hints + str(timeout), # timeout + ] + + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"ERROR: {result.stderr.strip()}", file=sys.stderr) + sys.exit(1) + + # Parse notification ID from "(uint32 N,)" + nid = result.stdout.strip().strip("()").split(",")[0].replace("uint32 ", "") + + if not actions_list: + print(f"notification:{nid}") + return + + # Wait for ActionInvoked or NotificationClosed signal + labels = [actions_list[i+1] for i in range(0, len(actions_list), 2)] + + monitor_cmd = [ + "gdbus", "monitor", "--session", + "--dest", "org.freedesktop.Notifications", + "--object-path", "/org/freedesktop/Notifications", + ] + + import signal + + class TimeoutError(Exception): + pass + + def alarm_handler(signum, frame): + raise TimeoutError() + + signal.signal(signal.SIGALRM, alarm_handler) + signal.alarm((timeout // 1000) + 2) # extra 2s grace + + try: + proc = subprocess.Popen(monitor_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + for line in proc.stdout: + if "ActionInvoked" in line and nid in line: + # Parse: /org/freedesktop/Notifications: org.freedesktop.Notifications.ActionInvoked (uint32 N, 'action_id') + parts = line.split("'") + if len(parts) >= 2: + action_id = parts[1] + try: + idx = int(action_id) + print(labels[idx]) + except (ValueError, IndexError): + print(action_id) + proc.terminate() + return + if "NotificationClosed" in line and nid in line: + print("dismissed") + proc.terminate() + return + except TimeoutError: + proc.terminate() + print("timeout") + except Exception as e: + print(f"ERROR: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/tools/gui_screenshot b/tools/gui_screenshot new file mode 100755 index 0000000..b748b35 --- /dev/null +++ b/tools/gui_screenshot @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +# description: Capture screenshots via KWin ScreenShot2 D-Bus (Wayland-safe). Args: [active|screen|area|window] [--output=path] [--screen=n] +"""KDE screenshot tool using org.kde.KWin.ScreenShot2. + +Modes: + active - capture the currently active window (default) + screen - capture entire screen (or specific with --screen=N) + area - capture a region (interactive picker) + window - capture window by title substring match + +Output defaults to /tmp/ollie_screenshot_<timestamp>.png +""" +import sys +import os +import time +import argparse +import subprocess +import tempfile + +SERVICE = "org.kde.KWin" +OBJ_PATH = "/org/kde/KWin/ScreenShot2" +IFACE = "org.kde.KWin.ScreenShot2" + + +def parse_args(): + p = argparse.ArgumentParser(description="KDE screenshot via KWin D-Bus") + p.add_argument("mode", nargs="?", default="active", + choices=["active", "screen", "area", "window"], + help="Capture mode (default: active)") + p.add_argument("--output", "-o", default="", + help="Output file path (default: /tmp/ollie_screenshot_<ts>.png)") + p.add_argument("--screen", type=int, default=0, + help="Screen index for 'screen' mode") + p.add_argument("--title", default="", + help="Window title substring for 'window' mode") + p.add_argument("--include-cursor", action="store_true", default=False) + p.add_argument("--include-decoration", action="store_true", default=True) + return p.parse_args() + + +def get_output_path(args): + if args.output: + return args.output + ts = int(time.time()) + return f"/tmp/ollie_screenshot_{ts}.png" + + +def capture_with_spectacle(mode, output, title=""): + """Fallback: use spectacle CLI if ScreenShot2 is unavailable.""" + cmd = ["spectacle", "-b", "-n", "-o", output] + if mode == "active": + cmd.append("-a") + elif mode == "screen": + cmd.append("-f") + elif mode == "area": + cmd.append("-r") + elif mode == "window": + cmd.append("-a") # fall back to active window + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + if result.returncode != 0: + return None + return output + + +def find_window_id_by_title(title): + """Use KWin scripting to find window UUID by title.""" + script = f""" + const clients = workspace.windowList(); + for (let i = 0; i < clients.length; i++) {{ + if (clients[i].caption.toLowerCase().includes('{title.lower()}')) {{ + print(clients[i].internalId); + break; + }} + }} + """ + # Load script via D-Bus + load_cmd = [ + "gdbus", "call", "--session", + "--dest", "org.kde.KWin", + "--object-path", "/Scripting", + "--method", "org.kde.kwin.Scripting.loadScript", "", script + ] + # This is complex; for now fall back to xdotool/wmctrl + try: + result = subprocess.run( + ["xdotool", "search", "--name", title], + capture_output=True, text=True, timeout=5 + ) + if result.stdout.strip(): + return result.stdout.strip().split("\n")[0] + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + return None + + +def capture_via_dbus(mode, output, args): + """Use KWin ScreenShot2 portal-style capture with pipe fd.""" + hints = { + "include-cursor": args.include_cursor, + "include-decoration": args.include_decoration, + } + + # ScreenShot2 methods use file descriptors which gdbus can't handle directly. + # Use the portal interface instead, or fall back to spectacle. + # Try the portal first: + portal_cmd = [ + "gdbus", "call", "--session", + "--dest", "org.freedesktop.portal.Desktop", + "--object-path", "/org/freedesktop/portal/desktop", + "--method", "org.freedesktop.portal.Screenshot.Screenshot", + "", # parent_window + "{'interactive': <false>}", + ] + + # Spectacle is more reliable for non-interactive capture + result = capture_with_spectacle(mode, output, args.title) + if result and os.path.isfile(result): + return result + + # Last resort: try import (ImageMagick) for X11 + if os.environ.get("DISPLAY"): + if mode == "active": + wid = subprocess.run( + ["xdotool", "getactivewindow"], + capture_output=True, text=True + ).stdout.strip() + if wid: + r = subprocess.run(["import", "-window", wid, output], + capture_output=True, timeout=10) + if r.returncode == 0: + return output + elif mode == "screen": + r = subprocess.run(["import", "-window", "root", output], + capture_output=True, timeout=10) + if r.returncode == 0: + return output + + return None + + +def main(): + args = parse_args() + output = get_output_path(args) + + result = capture_via_dbus(args.mode, output, args) + + if result and os.path.isfile(result): + size = os.path.getsize(result) + print(f"{result} ({size} bytes)") + else: + print("ERROR: screenshot capture failed", file=sys.stderr) + print("Ensure spectacle or imagemagick is installed", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tools/gui_shortcuts b/tools/gui_shortcuts new file mode 100755 index 0000000..c195513 --- /dev/null +++ b/tools/gui_shortcuts @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# description: Manage KDE global shortcuts via KGlobalAccel D-Bus. Args: <list|trigger|get|set> [component] [shortcut] [keys] +set -euo pipefail + +SERVICE="org.kde.kglobalaccel" +OBJ_PATH="/kglobalaccel" +IFACE="org.kde.KGlobalAccel" + +# Also component interface +COMP_OBJ="/component" +COMP_IFACE="org.kde.kglobalaccel.Component" + +cmd="${1:-list}" + +case "$cmd" in + list) + # List all registered shortcut components + components=$(gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.allComponents" 2>/dev/null | \ + sed "s/^(\[//;s/\],)$//;s/'//g;s/, /\n/g") + + if [ -z "$components" ]; then + echo "ERROR: KGlobalAccel not responding" >&2 + exit 1 + fi + + if [ -n "${2:-}" ]; then + # List shortcuts for specific component + comp="$2" + echo "# Shortcuts for: $comp" + gdbus call --session --dest "$SERVICE" \ + --object-path "/component/$comp" \ + --method "$COMP_IFACE.shortcutNames" 2>/dev/null | \ + sed "s/^(\[//;s/\],)$//;s/'//g;s/, /\n/g" + else + echo "$components" + fi + ;; + get) + # Get key binding for a shortcut + comp="${2:?usage: gui_shortcuts get <component> <shortcut_name>}" + name="${3:?usage: gui_shortcuts get <component> <shortcut_name>}" + + gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.shortcut" \ + "['$comp', '$name', '', '']" 2>/dev/null + ;; + trigger) + # Trigger (invoke) a global shortcut by component/name + comp="${2:?usage: gui_shortcuts trigger <component> <shortcut_name>}" + name="${3:?usage: gui_shortcuts trigger <component> <shortcut_name>}" + + # First get the shortcut keys, then simulate them + # Or use the invokeShortcut method if available + gdbus call --session --dest "$SERVICE" \ + --object-path "/component/$comp" \ + --method "$COMP_IFACE.invokeShortcut" "$name" 2>/dev/null && { + echo "triggered: $comp/$name" + exit 0 + } + + echo "ERROR: could not trigger $comp/$name" >&2 + exit 1 + ;; + set) + # Set key binding for a shortcut + comp="${2:?usage: gui_shortcuts set <component> <shortcut_name> <keys>}" + name="${3:?usage: gui_shortcuts set <component> <shortcut_name> <keys>}" + keys="${4:?usage: gui_shortcuts set <component> <shortcut_name> <keys>}" + + gdbus call --session --dest "$SERVICE" \ + --object-path "$OBJ_PATH" \ + --method "$IFACE.setShortcut" \ + "['$comp', '$name', '', '']" \ + "[['$keys']]" 0 2>/dev/null && { + echo "set: $comp/$name = $keys" + exit 0 + } + + echo "ERROR: could not set shortcut" >&2 + exit 1 + ;; + *) + echo "usage: gui_shortcuts <list|get|trigger|set> [component] [shortcut] [keys]" >&2 + echo "" >&2 + echo "Examples:" >&2 + echo " gui_shortcuts list - list all components" >&2 + echo " gui_shortcuts list kwin - list kwin shortcuts" >&2 + echo " gui_shortcuts trigger kwin 'Walk Through Windows' - trigger Alt+Tab" >&2 + echo " gui_shortcuts get kwin 'Walk Through Windows'" >&2 + exit 1 + ;; +esac diff --git a/tools/gui_windows b/tools/gui_windows new file mode 100755 index 0000000..4f080bf --- /dev/null +++ b/tools/gui_windows @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +# description: KWin window management via D-Bus scripting. Args: <list|focus|close|minimize|maximize|move|resize|desktops> [options] +"""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(): + if len(sys.argv) < 2: + print("usage: gui_windows <list|focus|close|minimize|maximize|move|resize|desktops|desktop> [args...]", file=sys.stderr) + sys.exit(1) + + cmd = sys.argv[1] + + if cmd == "list": + list_windows() + elif cmd == "focus": + if len(sys.argv) < 3: + print("usage: gui_windows focus <title_substr>", file=sys.stderr) + sys.exit(1) + focus_window(sys.argv[2]) + elif cmd == "close": + if len(sys.argv) < 3: + print("usage: gui_windows close <title_substr>", file=sys.stderr) + sys.exit(1) + close_window(sys.argv[2]) + elif cmd == "minimize": + if len(sys.argv) < 3: + print("usage: gui_windows minimize <title_substr>", file=sys.stderr) + sys.exit(1) + minimize_window(sys.argv[2]) + elif cmd == "maximize": + if len(sys.argv) < 3: + print("usage: gui_windows maximize <title_substr>", file=sys.stderr) + sys.exit(1) + maximize_window(sys.argv[2]) + elif cmd == "move": + if len(sys.argv) < 5: + print("usage: gui_windows move <title_substr> <x> <y>", file=sys.stderr) + sys.exit(1) + move_window(sys.argv[2], sys.argv[3], sys.argv[4]) + elif cmd == "resize": + if len(sys.argv) < 5: + print("usage: gui_windows resize <title_substr> <w> <h>", file=sys.stderr) + sys.exit(1) + resize_window(sys.argv[2], sys.argv[3], sys.argv[4]) + elif cmd == "desktops": + list_desktops() + elif cmd == "desktop": + if len(sys.argv) < 3: + print("usage: gui_windows desktop <number>", file=sys.stderr) + sys.exit(1) + switch_desktop(sys.argv[2]) + else: + print(f"unknown command: {cmd}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main()