437 lines
14 KiB
Python
Executable File
437 lines
14 KiB
Python
Executable File
#!/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())
|