tags: bobbins linux title: My Bobbins Script # 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](Schmerp_01). 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](/linux/setup-notes/bobbins_001.kdecustomshortcuts.xz) 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 ```bash #!/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` ```bash #!/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` ```bash # $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`. ```bash #!/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 ```bash 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 ```bash 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` ```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 ```json { "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 ```json { "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 ```py #!/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) ```