Add the ability for modes to dwm. This will allow things like launching programs while a game is running.

This commit is contained in:
Storm Dragon
2025-12-15 13:15:31 -05:00
parent e8954b3af5
commit e60aad0026

436
usr/local/bin/keymode.py Executable file
View 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())