Add the ability for modes to dwm. This will allow things like launching programs while a game is running.
This commit is contained in:
436
usr/local/bin/keymode.py
Executable file
436
usr/local/bin/keymode.py
Executable file
@@ -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):
|
||||
# <keybind key="Escape">
|
||||
# <action name="Execute">
|
||||
# <command>~/.config/i3/scripts/keymode.py --mode ratpoison</command>
|
||||
# </action>
|
||||
# </keybind>
|
||||
#
|
||||
# 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())
|
||||
Reference in New Issue
Block a user