From e60aad00260db772be52963ec59d3faa5d9992fc Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Mon, 15 Dec 2025 13:15:31 -0500 Subject: [PATCH] Add the ability for modes to dwm. This will allow things like launching programs while a game is running. --- usr/local/bin/keymode.py | 436 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 436 insertions(+) create mode 100755 usr/local/bin/keymode.py diff --git a/usr/local/bin/keymode.py b/usr/local/bin/keymode.py new file mode 100755 index 0000000..8cce1da --- /dev/null +++ b/usr/local/bin/keymode.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 + +# Keymode - Standalone modal keyboard tool for window managers +# Works on both X11 and Wayland +# Written by Storm Dragon https://stormux.org +# +# Copyright (c) 2025 Storm Dragon +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('Gdk', '3.0') +from gi.repository import Gtk, Gdk, GLib + +# Try to import GTK Layer Shell for Wayland compositor support +try: + gi.require_version('GtkLayerShell', '0.1') + from gi.repository import GtkLayerShell + HAS_LAYER_SHELL = True +except (ValueError, ImportError): + HAS_LAYER_SHELL = False + +import subprocess +import sys +import os +import shutil +from pathlib import Path +import argparse + +# TOML loading - try stdlib tomllib (Python 3.11+), fallback to toml package +try: + import tomllib + def load_toml(path): + with open(path, 'rb') as f: + return tomllib.load(f) +except ImportError: + try: + import toml + def load_toml(path): + return toml.load(path) + except ImportError: + print("Error: No TOML parser available.", file=sys.stderr) + print("Install: pip install toml OR upgrade to Python 3.11+", file=sys.stderr) + sys.exit(1) + + +def get_config_path(): + """Get the path to the config file using XDG_CONFIG_HOME""" + config_home = os.environ.get('XDG_CONFIG_HOME', os.path.expanduser('~/.config')) + return Path(config_home) / 'stormux' / 'keymode.toml' + + +def play_sound_async(command): + """Play a sound asynchronously without blocking (pattern from sound.py:24-36)""" + if not command or not shutil.which('play'): + return + + try: + subprocess.Popen( + command, + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True + ) + except Exception: + # Silently ignore sound playback errors + pass + + +def play_sound_reversed(command): + """Play sound in reverse by appending 'reverse' to sox command (pattern from sound.py:79)""" + if not command: + return + + reversed_command = command + " reverse" + play_sound_async(reversed_command) + + +def execute_command(command): + """Execute configured command asynchronously""" + if not command: + return + + try: + # Expand ~ and environment variables + expanded_command = os.path.expanduser(command) + expanded_command = os.path.expandvars(expanded_command) + + # Execute in background + subprocess.Popen( + expanded_command, + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True + ) + except Exception as e: + print(f"Error executing command: {e}", file=sys.stderr) + + +class KeyMode_Window(Gtk.Window): + """Main window for modal keyboard capture""" + + def __init__(self, modeName, config): + super().__init__(title=f"keymode-{modeName}") + self.modeName = modeName + self.config = config + self.modeConfig = config['mode'][modeName] + + # Initialize GTK Layer Shell if available (for Wayland) + # This allows the window to receive focus even when other windows are fullscreen + if HAS_LAYER_SHELL and GtkLayerShell.is_supported(): + GtkLayerShell.init_for_window(self) + # Use overlay layer to ensure focus even with fullscreen windows + GtkLayerShell.set_layer(self, GtkLayerShell.Layer.OVERLAY) + # Request keyboard interactivity + GtkLayerShell.set_keyboard_mode(self, GtkLayerShell.KeyboardMode.EXCLUSIVE) + # Center on screen + GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.TOP, False) + GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.BOTTOM, False) + GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.LEFT, False) + GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.RIGHT, False) + + # Window properties for X11 and Wayland compatibility + self.set_default_size(200, 80) + self.set_decorated(False) + self.set_skip_taskbar_hint(True) + self.set_skip_pager_hint(True) + self.set_keep_above(True) + + # Center window on screen (for X11 and non-layer-shell Wayland) + self.set_position(Gtk.WindowPosition.CENTER) + + # Optional: try to set semi-transparent background + try: + screen = self.get_screen() + visual = screen.get_rgba_visual() + if visual: + self.set_visual(visual) + except Exception: + pass + + # Create label showing mode name + description = self.modeConfig.get('description', modeName) + label = Gtk.Label(label=description) + label.set_margin_top(20) + label.set_margin_bottom(20) + label.set_margin_start(20) + label.set_margin_end(20) + + # Set accessible name for screen readers + try: + accessible = label.get_accessible() + if accessible: + accessible.set_name(f"Mode: {description}") + except Exception: + pass + + self.add(label) + + # Connect signals + self.connect("realize", self.on_realize) + self.connect("key-press-event", self.on_key_press) + + # Timeout handling + timeout = config.get('settings', {}).get('timeout_seconds', 0) + if timeout > 0: + GLib.timeout_add_seconds(timeout, self.on_timeout) + + def on_realize(self, widget): + """Called when window is realized - grab focus and play sound""" + # Grab keyboard focus + self.get_window().focus(Gdk.CURRENT_TIME) + + # Play entry sound + sound = self.modeConfig.get('sound') + if sound: + play_sound_async(sound) + + def on_timeout(self): + """Called when timeout expires""" + # Play reversed sound + sound = self.modeConfig.get('sound') + if sound: + play_sound_reversed(sound) + + # Exit + Gtk.main_quit() + return False + + def keyval_to_config_string(self, event): + """Convert Gdk.Event to config string like 'Control+c' or 'F1'""" + modifiers = [] + + # Check for modifier keys + if event.state & Gdk.ModifierType.CONTROL_MASK: + modifiers.append("Control") + if event.state & Gdk.ModifierType.MOD1_MASK: # Alt + modifiers.append("Alt") + if event.state & Gdk.ModifierType.SHIFT_MASK: + modifiers.append("Shift") + if event.state & Gdk.ModifierType.MOD4_MASK: # Super + modifiers.append("Super") + + # Get key name + keyName = Gdk.keyval_name(event.keyval) + + # Build config string + if modifiers: + return "+".join(modifiers + [keyName]) + return keyName + + def on_key_press(self, widget, event): + """Handle keyboard input""" + # Convert keyval to config string + keyString = self.keyval_to_config_string(event) + + # Check if Escape (exit without action) + if event.keyval == Gdk.KEY_Escape: + sound = self.modeConfig.get('sound') + if sound: + play_sound_reversed(sound) + Gtk.main_quit() + return True + + # Look up in config + keys = self.modeConfig.get('keys', {}) + if keyString in keys: + command = keys[keyString] + + # Play reversed sound + sound = self.modeConfig.get('sound') + if sound: + play_sound_reversed(sound) + + # Execute command + execute_command(command) + + # Exit + Gtk.main_quit() + return True + + # Unknown key - ignore + return True + + +def generate_example_config(configPath): + """Generate example config file""" + configPath.parent.mkdir(parents=True, exist_ok=True) + + example = """# Keymode Configuration +# Configuration for modal keyboard interaction tool + +[settings] +# Timeout in seconds (0 = no timeout, waits indefinitely) +timeout_seconds = 0 + +# Example mode: ratpoison-style application launcher +[mode.ratpoison] +description = "Ratpoison Mode" +# Sound played when entering mode (sox command) +# Will be played in reverse when exiting +sound = "play -qV0 \\"|sox -np synth .07 sq 400\\" \\"|sox -np synth .5 sq 800\\" fade h 0 .5 .5 norm -20" + +[mode.ratpoison.keys] +# Format: key = "command to execute" +# Special keys use quotes: "F1", "F2", etc. +# Modifiers: "Control+c", "Alt+f", "Shift+x", "Super+r" +# Escape always exits without action (built-in) +# Unbound keys are ignored - the mode waits for a valid key + +c = "lxterminal" +w = "brave" + +# Window Manager Integration Examples: +# +# i3/Sway (~/.config/i3/config or ~/.config/sway/config): +# bindsym Escape exec ~/.config/i3/scripts/keymode.py --mode ratpoison +# +# Openbox (~/.config/openbox/rc.xml): +# +# +# ~/.config/i3/scripts/keymode.py --mode ratpoison +# +# +# +# Fluxbox (~/.fluxbox/keys): +# Escape :Exec ~/.config/i3/scripts/keymode.py --mode ratpoison +""" + + with open(configPath, 'w') as f: + f.write(example) + + print(f"Example config generated at: {configPath}") + + +def validate_config(config, modeName): + """Validate configuration and provide helpful error messages""" + if 'mode' not in config: + print("Error: No modes defined in config.toml", file=sys.stderr) + sys.exit(1) + + if modeName not in config['mode']: + available = ', '.join(config['mode'].keys()) + print(f"Error: Mode '{modeName}' not found.", file=sys.stderr) + print(f"Available modes: {available}", file=sys.stderr) + sys.exit(1) + + modeConfig = config['mode'][modeName] + if 'keys' not in modeConfig or not modeConfig['keys']: + print(f"Error: Mode '{modeName}' has no keybindings defined", file=sys.stderr) + sys.exit(1) + + return True + + +def list_modes(config): + """List available modes from config""" + if 'mode' not in config or not config['mode']: + print("No modes defined in config") + return + + print("Available modes:") + for modeName, modeConfig in config['mode'].items(): + description = modeConfig.get('description', 'No description') + numKeys = len(modeConfig.get('keys', {})) + print(f" {modeName}: {description} ({numKeys} keybindings)") + + +def parse_args(): + """Parse command-line arguments""" + parser = argparse.ArgumentParser( + description="Modal keyboard input handler for window manager by Storm Dragon https://stormux.org", + epilog="Example: keymode --mode ratpoison" + ) + parser.add_argument( + '--mode', '-m', + help='Mode name from config.toml (e.g., ratpoison)' + ) + parser.add_argument( + '--config', '-c', + help=f'Path to config file (default: {get_config_path()})' + ) + parser.add_argument( + '--generate-config', + action='store_true', + help='Generate example config and exit' + ) + parser.add_argument( + '--list-modes', + action='store_true', + help='List available modes from config' + ) + parser.add_argument( + '--version', '-v', + action='version', + version='keymode 2025.12.13' + ) + return parser.parse_args() + + +def main(): + """Main entry point""" + args = parse_args() + + # Get config path + configPath = Path(args.config) if args.config else get_config_path() + + # Handle --generate-config + if args.generate_config: + generate_example_config(configPath) + return 0 + + # Check if config exists + if not configPath.exists(): + print(f"Error: Config file not found: {configPath}", file=sys.stderr) + print(f"Run: {sys.argv[0]} --generate-config", file=sys.stderr) + return 1 + + # Load config + try: + config = load_toml(str(configPath)) + except Exception as e: + print(f"Error loading config: {e}", file=sys.stderr) + return 1 + + # Handle --list-modes + if args.list_modes: + list_modes(config) + return 0 + + # Require --mode for normal operation + if not args.mode: + print("Error: --mode is required", file=sys.stderr) + print(f"Run: {sys.argv[0]} --list-modes to see available modes", file=sys.stderr) + return 1 + + # Validate config + validate_config(config, args.mode) + + # Check if sox is available (warn but continue) + if not shutil.which('play'): + print("Warning: 'play' command not found. Audio feedback disabled.", file=sys.stderr) + print("Install sox for sound effects: sudo apt install sox", file=sys.stderr) + + # Create and show window + window = KeyMode_Window(args.mode, config) + window.show_all() + + # Run GTK main loop + Gtk.main() + + return 0 + + +if __name__ == '__main__': + sys.exit(main())