#!/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())