KDE 6 Breaks this
So I finally put Kubuntu 25.10 on a Thinkpad that needed a reinstall. It comes with KDE 6,
and the custom keyboard shortcuts of old is gone. Moreover, mass adding shortcuts as with Bobbins,
which meant hundreds of them, just crashes the settings app. So I needed a different approach.
What I came up with is a simple app with Tkinter which displays a list of shortcuts which
one can press. I called it schmoop for the sake of giving it a silly name like schmerp.
Then, I bind e.g. A-C-M-l to the command schmoop A-C-M-l, which then finds ~/.schmoop/A-C-M-l.schmoop
which contains eybindings in a simple form:
x command args
y command args
and generally each keystroke then launches a script or application. Generally that places most
things two combos away, one to load schmoop, and the other to follow it. For other things,
there is the schmerp app I wrote a while back, which presents a simple text entry into
which you can use shorthands for commands.
Old Content
Note: This is a hack that has evolved organically. It is a mess, but does what I want. At some point, once its functionality stabilises, I may look to rewriting it in a more sensible form.
Named bobbins just for the sake of giving it a name distinct from everything else.
A counterpart to Schmerp. I assign, en masse, keyboard combos like
Meta+Shift+9 or Meta+Alt+Delete to a single script, passing the key combo as a parameter,
in the A-C-M-S-x format (Alt-Control-Meta-Shift-x -- modifiers in lex order). Then I
can easily modify the bindings by editing a shell script, or Python script, and can
have these scripts defer to others as desired. This avoids the need to do tons of point
and click stuff in KDE settings. By default, the bobbins script pops up a text box
saying what combo it has received, inviting the user to press e to edit the bobbins script.
These use a couple of simple Python Tk scripts (see PythonTk_01).
Creating a few dozen shortcuts is a tiresome process. In any event, here is a custom shortcut
schema you can (decompress and) import that assigns every Alt-Meta, Alt-Control-Meta, Control-Meta, and Meta-Shift
combination going (except those that KDE assigns by default, which I've marked with an asterisk).
It may be necessary to edit ~/.config/kglobalshortcutsrc and remove the content of
the [khotkeys] section. Do this after deleting groups in which these key combos are already
bound. I find it safest to delete all current custom binding groups, then log out, then Ctrl-Alt-F3
to get to a text terminal, log in, then delete the [khotkeys] section from there, and then
log in again and import the schema file. KDE's hotkey manager doesn't work well with clashes, and deleting
groups and such in my experience. Once this is done, handling the shortcut is done inside the bobbins
script, which may then delegate to other scripts.
Now bobbins must be in the path that KDE sees, not the path you see when you open a terminal
(modifying the path in your .bashrc does not affect the path that the desktop session sees).
Thus the script bobbins needs to be in your path. This script then looks for $HOME/bin/bobbins.sh
and executes that. That script should exit to prevent fall through to the rest of the bobbins
script which tries a few default bindings (such as A-C-M-delete to edit your bobbins.sh).
The bobbins script
#!/bin/bash
. bobbins_common.sh
fsplash1 "$1" &
MYBOBBINS="$HOME/bin/bobbins.sh"
if [ -f "$MYBOBBINS" ]; then
# bobbins.sh should exit if we don't want it to fall through
. "$MYBOBBINS"
fi
# REMEMBER the ;;
case "$1" in
A-C-M-delete)
gvim ~/bin/bobbins.sh
;;
edit)
gvim ~/bin/bobbins.sh
;;
*)
bobbins_default "$1" # defined in bobbins_common.sh
;;
esac
Example bobbins.sh
#!/bin/bash
case "$1" in
# media player controls
A-C-M-0)
mp "$MP",p >> "$LOG"
exit
;;
A-C-M-minus)
mp "$MP",v-4 >> "$LOG"
exit
;;
A-C-M-plus)
mp "$MP",v+4 >> "$LOG"
exit
;;
esac
bobbins_common.sh
# $HOME/.bobbins contains variables
# C-w gf to edit in new tab; C-w C-f to open in split
if [ -e "$HOME/.bobbins" ]; then . "$HOME/.bobbins"; fi
# ~/.bobbins.log is the log file
# if you're wondering why I state the f'ing obvious,
# it's so I can position the cursor over ~/.bobbins.log
# in vim and use C-w gf to open it in a new tab
LOG=~/.bobbins.log
echo "[$(date +"%Y/%m/%d %H:%M:%S")] bobbins $@" >> $LOG
n() {
kdialog --passivepopup "$*" 5
}
nobobbins() {
echo -e "Bobbins $1 (press e to edit bobbins, b to edit .boink)" | tkt - "e=bobbins edit" "b=bobbins editboink"
}
bobbins_default() {
case "$1" in
edit)
gvim ~/bin/bobbins
;;
editboink)
gvim ~/.boink
;;
*)
if command -v boink >& /dev/null; then
boink "$1" || nobobbins "$1"
else
nobobbins "$1"
fi
;;
esac
}
Boink
Boink is another simple script, using awk, and a simple file of the form
S-8 lowriter
S-9 gimp a.png
where awk finds the first line matching the combo (e.g. S-9), removes the
combo from the start and runs the rest as a shell command. It returns 0 if
the combo is in the file, or 1 if not. That way, the boink "$1" || echo ...
line will fall back to telling the user what shortcut was pressed and inviting
them to edit bobbins.sh.
#!/bin/bash
BOINK="$HOME/.boink"
if [ ! -f "$BOINK" ]; then exit 1; fi
awk 'BEGIN { retval = 1 } "'"$1"'" == $1 { $1 = ""; $0 = $0; $1 = $1; $0 = $0; system($0); retval = 0; } END { exit(retval); }' "$BOINK"
The $1 = ""; $0 = $0; $1 = $1; $0 = $0; craziness is because it seems awk doesn't
have a shift that drops the first filed and shifts the rest along. This is one of the
things I'm surprised hasn't been added to the GNU version of awk. Note that if we have
multiple lines starting with e.g. S-3, then all of these will run if we run boink S-3,
not just the first. We can change this behaviour by adding an exit, viz
awk 'BEGIN { retval = 1 } "'"$1"'" == $1 { $1 = ""; $0 = $0; $1 = $1; $0 = $0; system($0); retval = 0; exit } END { exit(retval); }' "$BOINK"
Another possibility is to run every matching line, but stop if one of those commands returns a nonzero return code, viz
awk 'BEGIN { retval = 1 } "'"$1"'" == $1 { $1 = ""; $0 = $0; $1 = $1; $0 = $0; if( system($0) != 0 ) { exit }; retval = 0 } END { exit(retval); }' "$BOINK"
Entirely up to you. If you're wondering why, it is that it is more concise to put commands in .boink
than to edit bobbins as each only takes a single line, rather than playing with bash case syntax.
Boink v2
Having done the above fun with awk, I decided to write a more full featured boink using Python.
This can also load commands from a simple json file, and in the json file, environment variables
may be specified in a command.
Example .boink.json
{
"M-S-p": {
"type": "commands",
"env": { "BG":"yellow", "FG":"black" },
"commands": [
["do","something"],
"say hello world"
]
},
"M-S-y": {
"type": "command",
"command": "another command"
},
"M-S-o": [ "gimp" ],
"M-S-i": "krita"
}
Here the json is a dict, key combos are the keys in the dict, then a value is either a string, which is split by whitespace, and turned into a
{
"type": "command",
"command": [ "a", "b", "etc" ]
}
If the value is a list, it is turned into the above but without splitting at whitespace.
If the value is a dict, it must be of the type=command form above, or
{
"type": "commands",
"env": { "BG":"yellow", "FG": black },
"commands": [
"command 1",
"command 2"
]
}
where the env element is optional, and takes the form of either a single A=BCD string,
or a list of such strings, or a dict of key-value pairs that are used to produce a modified
environment that is passed to the command when run.
Source
#!/usr/bin/env python
import json, subprocess, os, re, sys
# .boink.json overrides .boink
def home(x):
return os.path.expanduser(os.path.join("~",x))
def getlines(f):
return f.read().rstrip().splitlines()
wsre = re.compile(r"\s+")
def sws(x):
return wsre.split(x)
try:
with open(home(".boink")) as f:
boink_txt = getlines(f)
except Exception as e: # permission or file not found or other os error
print("No .boink",e)
boink_txt = []
try:
with open(home(".boink.json")) as f:
boink_json = json.load(f)
except json.decoder.JSONDecodeError as e:
msg = f"Failed to parse json: {e}"
subprocess.run(["tkt","--error"],input=msg.encode())
exit(1)
except Exception as e: # permission or file not found or other os error
print("Bad json",e)
boink_json = {}
boink = {}
for line in boink_txt:
hotkey, *cmd = line.split(" ")
boink[hotkey] = { "type": "command", "command": cmd }
example = """
{
"A-C-M-s": "something", // split at whitespace
"A-C-M-a": ["something","and","params"],
"A-C-M-d": { "type": "commands", "commands": [ "hello", ["world","and"] ] }
}
"
"""
for hotkey,v in boink_json.items():
if type(v) is str:
cmd = wsre.split(v)
boink[hotkey] = { "type": "command", "command": cmd }
elif type(v) is list:
boink[hotkey] = { "type": "command", "command": v }
elif type(v) is dict:
boink[hotkey] = v
if len(sys.argv) == 1:
print("No arg")
exit(1)
try:
arg = sys.argv[1]
what = boink[arg]
whattype = what["type"]
except (KeyError, IndexError):
exit(1)
cmds = []
env = os.environ
try:
if "env" in what:
whatenv = what["env"]
if type(whatenv) is str:
a,b = whatenv.split("=",1)
env[a] = b
elif type(whatenv) is list:
for x in whatenv:
if type(x) is str:
a,b = x.split("=",1)
env[a] = b
else:
raise ValueError(f"Invalid environment variable in json")
elif type(whatenv) is dict:
for k,v in whatenv.items():
if not type(v) is str:
v = str(v)
env[k] = v
match whattype:
case "commands":
cmdsin = what["commands"]
for cmd in cmdsin:
if type(cmd) is str:
cmds.append(wsre.split(cmd))
elif type(cmd) is list:
cmds.append(cmd)
else:
print("cmd should be list or str")
exit(1)
case "command":
cmd = what["command"]
if type(cmd) is str:
cmds.append(wsre.split(cmd))
elif type(cmd) is list:
cmds.append(cmd)
else:
print("cmd should be list or str")
exit(1)
except Exception as e:
msg = f"Failed to parse command: {what} -- {e}"
subprocess.run(["tkt","--error"],input=msg.encode())
exit(1)
for cmd in cmds:
subprocess.run(cmd,env=env)