Skip to content

Toshifying a New Linux Application

RedBearAK edited this page Aug 8, 2024 · 4 revisions

This is a guide to how to make your own custom keymaps for apps that aren't part of the default Toshy config.

Intro

The Toshy config file by default covers the following in Linux:

  • Some common general (global) shortcuts

  • A selection of "wordwise" shortcuts like "delete line"

  • Shortcuts specific to certain types of apps/windows:

    • Terminal emulator apps
    • Remote desktop apps
    • Virtual machine apps
    • File managers
    • Web browsers
    • Some dialogs and "child" windows
  • App-specific shortcuts for some individual apps:

    • Firefox and variants
    • Chrome/Chromium and variants
    • Visual Studio Code and variants
    • Sublime Text apps
    • JetBrains apps
    • Some other common code/text editors (xed, Kate/KWrite, GNOME Text Editor, etc.)
    • A few miscellaneous unrelated apps

It may be that you will be using a Linux app that is cross-platform or just closely resembles an app that you use on macOS, and has no app-specific shortcuts in the default Toshy config. You may want to "Toshify" some shortcuts in that specific Linux app to make it behave more like the macOS app you're used to. In other words, you might want to make a keymap with some app-specific shortcut remaps for that app.

Getting the app class

To "Toshify" a Linux app, you'll need to know the "application class" of the app you want to make a keymap for. The X11/Xorg server calls this attribute WM_CLASS. Under Wayland compositors it may have different names like app_id or resourceClass.

To make it easier to get this info, I put together a "diagnostic dialog" using a Zenity window that will pop up when you use a certain keyboard shortcut. Naturally, the Toshy config has to be working and enabled for this shortcut to do anything. The shortcut is the physical equivalent of:

Shift+Option+Cmd+I,I (quickly double-tap the "I" key)

I say "physical equivalent" a lot with shortcuts where Toshy is involved, because Toshy works with multiple keyboard types and the shortcut should work on the keys at the same physical location as those keys on an Apple keyboard, even if you are on a PC keyboard. That equivalent physicality is half the point of what Toshy does. Being able to use macOS shortcuts from muscle memory, on Linux.

The diagnostic dialog shortcut will bring up a dialog showing a lot of different context info that the keymapper sees at that moment, such as the keyboard device you used to invoke the shortcut, and whether the current window is part of any of the major app groupings in the config. Right at the top you'll see the app class you need to make a new keymap for your target application. Easy-peasy.

If you are still in X11/Xorg, you can also get the app class and window name with this command, after making sure xprop is installed:

xprop WM_CLASS _NET_WM_NAME WM_NAME

After running the command the mouse cursor turns into a cross, and then you have to click on the window you want to identify. The attributes you asked for will appear in the terminal where you ran the command.

In Wayland environments it's not as easy, but if Toshy is compatible with the environment, you can see the context attributes if you use the toshy-debug (verbose output) command in a terminal (leave the keyboard alone for a few seconds while the keymapper starts). Then to get it to show the context in the log, you have to use a shortcut that will get transformed, like Shift+Cmd+Left_Brace (left bracket key). This should produce a logging block like this (notice the bigger block with the indented list of keymap names):

(II) in LEFT_BRACE (press)
(DD) KDE_DBUS_SVC: Using D-Bus interface 'org.toshy.Toshy' for window context
(DD) on_key LEFT_BRACE press
(DD) KBTYPE: 'Windows' | (CACHED) Rgx matched on dev: 'AT Translated Set 2 keyboard'

(DD) WM_CLASS: 'Code' | WM_NAME: '● Toshifying-a-New-Linux-Application.md - toshy (Workspace) - Visual Studio Code'
(DD) DEVICE: 'AT Translated Set 2 keyboard' | CAPS_LOCK: 'False' | NUM_LOCK: 'False'
(DD) ACTIVE KEYMAPS:
     'User hardware keys', 'Currency character overlay', 'User overrides: KDE',
     'VSCodes overrides for not Chromebook/IBM', 'VSCodes', 'Cmd+Dot not in
      … terminals', 'GenGUI overrides: not Chromebook', 'GenGUI overrides: KDE',
     'General GUI', 'Diagnostics'
(DD) COMBO: VCmd-LShift-LEFT_BRACE => Ctrl-PAGE_UP in KMAP: 'VSCodes'
(DD) spent modifiers []
(OO) release LEFT_SHIFT 1720492907.4650471
(OO) press PAGE_UP 1720492907.4953766
(OO) release PAGE_UP 1720492907.50186
(OO) press LEFT_SHIFT 1720492907.5200746

(II) in LEFT_BRACE (release)
(DD) KDE_DBUS_SVC: Using D-Bus interface 'org.toshy.Toshy' for window context
(DD) on_key LEFT_BRACE release

This log output is of course the same in X11/Xorg environments, so technically you wouldn't need xprop to get the window info. But in Wayland this is the main way you would identify the app class.

These alternate techniques are just informational, as long as the diagnostic dialog shortcut is working.

Where to put the new keymap (for testing)

The config file (~/.config/toshy/toshy_config.py) now has several marked "slices" where it is safe to put custom code that will be preserved if you re-install or upgrade Toshy. Outside of those slices, your code will appear to vanish if you re-install Toshy. But it will actually be in a backup folder that preserves the previous state of your config files each time the Toshy installer runs.

The "slice" that's good for something like this is the one tagged with user_apps. That slice is also the intended spot to put fixes for laptop hardware/media keys, if necessary, like brightness and volume controls. In the default config, there's just one empty keymap in that slice, with an appropriate when conditional for that purpose.

Now you want to take that app class string (example: 'the.appclass.string') you got from the context info dialog, and make a new keymap, with a sensible name:

###################################################################################################
###  SLICE_MARK_START: user_apps  ###  EDITS OUTSIDE THESE MARKS WILL BE LOST ON UPGRADE


keymap("User hardware keys", {
    # PUT UNIVERSAL REMAPS FOR HARDWARE KEYS HERE
    # KEYMAP WILL BE ACTIVE IN ALL DESKTOP ENVIRONMENTS/DISTROS


}, when = lambda ctx:
    cnfg.screen_has_focus and
    matchProps(not_clas=remoteStr)(ctx)
)


keymap("Some New Linux Application", {
  # Syntax inside the Python dictionary of remaps:
  # Combo_as_dict_key: Remapped_Key_Combo_or_Macro_as_dict_value,   # Comment about what shortcut does
}, when = matchProps(clas="^the.appclass.string$")
)


###  SLICE_MARK_END: user_apps  ###  EDITS OUTSIDE THESE MARKS WILL BE LOST ON UPGRADE
###################################################################################################

Putting remaps in the keymap

So we have a new keymap, what do you do with it? First thing is to probably make a list of the native shortcuts in the Linux app that you want to map different Mac-like shortcuts onto. The input shortcuts must be combos, not keys. On the output side, it is possible to do combos, keys (even modifiers) and macros (sequences). Or just general function calls, since the whole config file is still Python. There are also string and Unicode processor "helpers" built into the keymapper currently.

The input combos need to be what the keys you want to use will be after modmapping, which will often put modifier keys in different places. On the other hand the output combos should just be what the app naturally expects for each shortcut.

Here are some examples of all the different things that can be done. The C() function is shorthand for Combo(), and ST() and UC() are shorthand for string and Unicode processor functions. (The string processor will actually also process any non-ASCII characters in the string through the Unicode processor, so it's often not necessary to call the Unicode processor directly.)

(Note: Most of the config file has remap lines in keymaps aligned to columns 5, 33, and 65 for readability.)

keymap("Some New Linux Application", {
    # Syntax inside the Python dictionary of remaps:
    # Combo_as_dict_key: Remapped_Key_Combo_or_Macro_as_dict_value,   # Comment about what shortcut does

    # "RC" is RIGHT_CTRL, but represents the virtualized Cmd key after modmapping!

    C("RC-N"):                  C("Alt-N"),                     # Make Cmd+N do Alt+N, for some reason
    C("RC-M"):                 [sleep(0.5), ST("Macro")],       # Wait 0.5 seconds, type out the string "Macro"
    C("Shift-RC-M"):            Key.LEFT_META,                  # Make Shift+Cmd+K send a left Meta key press/release
    C("RC-R"):                  UC(0x1F339),                    # Cmd+R will type keys to create a Unicode rose (needs `ibus`)
    C("Shift-RC-R"):            ST("🌹"),                       # Should have the same result as the Cmd+R Unicode remap
}, when = matchProps(clas="^the.appclass.string$")

The following is not currently done anywhere in the config file, but we could also just store the "dictionary" argument in a variable, and give the variable as the second argument to the keymap function.

my_remaps = {
    # Syntax inside the Python dictionary of remaps:
    # Combo_as_dict_key: Remapped_Key_Combo_or_Macro_as_dict_value,   # Comment about what shortcut does

    # "RC" is RIGHT_CTRL, but represents the virtualized Cmd key after modmapping!

    C("RC-N"):                  C("Alt-N"),                     # Make Cmd+N do Alt+N, for some reason
    C("RC-M"):                 [sleep(0.5), ST("Macro")],       # Wait 0.5 seconds, type out the string "Macro"
    C("Shift-RC-M"):            Key.LEFT_META,                  # Make Shift+Cmd+K send a left Meta key press/release
    C("RC-R"):                  UC(0x1F339),                    # Cmd+R will type keys to create a Unicode rose (needs `ibus`)
}

keymap("Some New Linux Application", my_remaps, when = matchProps(clas="^the.appclass.string$")

This is just another way to look at what's happening when we make a keymap.

If you figure out a nice keymap for an app, even if there are just a couple of remaps in it, submit it in an issue and we will see if it's suitable for inclusion in the default Toshy config file. That way, everyone who installs Toshy can get the same remaps. Including you, when you install Toshy on other machines. If you don't think the keymap would be useful to anyone else, just keep it in your user_apps slice, and maybe make a backup of your config file in another location periodically.

Understanding the conditional expressions

This is just an "FYI" section if you want to know more about how the conditionals work.

The conditionals for the when argument may seem complicated, and they can be. Here's how they work. The expression given to when needs to return a "callable" (aka, a function). The matchProps() function returns a function already, so it's good by itself. But when you need to deal with multiple conditions at once, you need to wrap the expressions in a lambda function, then give the context object to any function inside the lambda.

This is from the example new keymap:

when = matchProps(clas="^the.appclass.string$")

In this case, it doesn't need (ctx) after it, because the "callable" given to when automatically gets called with the context object. This can lead to some confusion when you try to put multiple conditions together, like in the sample hardware keys keymap conditional:

when = lambda ctx:
    cnfg.screen_has_focus and
    matchProps(not_clas=remoteStr)(ctx)

That extra (ctx) has to come after the usage of any function like matchProps() inside a lambda in this argument, because the context object is actually only given to the lambda function (the outer callable), and then we have to pass the reference to that object on to other functions used inside the lambda. If you leave the extra (ctx) off any function call inside the lambda, the conditional won't work, or will produce an error.

For clarity, the context object is only represented by ctx here because that's what we put after the lambda. That's it. That's the end of the story for why it's ctx in the example. It could just as easily look like this:

when = lambda the_lambda_parameter_name:
    cnfg.screen_has_focus and
    matchProps(not_clas=remoteStr)(the_lambda_parameter_name)

In Kinto's config (where Toshy's config came from) you might see usage of ctx when calling on its attributes, like ctx.wm_class == "some-app", but these are also just naming conventions within each lambda ctx: wrapper that handles those conditional expressions. I was confused about this for a long time.

As another example of what's going on here, these two when conditionals are identical in how they would work, one just has a lambda wrapper, and the one with the lambda could also include other conditions, like simple comparisons or booleans:

when = matchProps(clas="^the.appclass.string$")

when = lambda ctx: matchProps(clas="^the.appclass.string$")(ctx)

With more conditions added:

when = lambda ctx:
    cnfg.screen_has_focus and
    some_custom_variable == 'a_string' and
    matchProps(clas="^the.appclass.string$")(ctx)

Keeping the conditionals from impacting performance

It can be critical at times to control the order of the conditions in the lambda for conditional expressions. Some are much heavier than others, and conditional expressions are evaluated on every key press (and release). I recently implemented a bunch of order-of-operations fixes that significantly reduced CPU usage of the keymapper while typing, down to very manageable levels, even for older/slower systems. Possibly even some final-generation single-core systems, but certainly any dual-core/quad-core CPUs from the last 15 years should do just fine. I saw 3-4% on a Celeron mini PC with a J4125 chip, with a test that was probably the equivalent of 300wpm. Not bad.

Using matchProps() for a simple app class match, or even a multi-condition match on app class, name, device name, or NumLock/CapsLock LED states, should be pretty lightweight and fast. So this is fine, even though there are multiple attributes of the context object to check:

...
, when = matchProps(
    clas="^the.appclass.string$", 
    name="^Some Specific Dialog Name$", 
    capslk=True
    )
)

On the other hand, every time you need to ask matchProps() to iterate through a "list of dicts" like remotes_lod, it becomes a recursive operation, splitting apart the "list of dicts" into individual calls to matchProps() with the set of parameters from every dictionary inside the list. If a list of conditions is just a simple list that doesn't check multiple attributes of the context object, like most of the "app class" lists in the config, it's fastest to turn the list into a regex pattern string, with the included custom function:

simplelist = [
    'app1',
    'app2',
    'app3'
]
simplelistStr = toRgxStr(simple_list)
# Produces: "^app1$|^app2$|^app3$"

# fast/light, simple string comparison check
keymap("app", {
    # some remaps
}, when = matchProps(clas=simplelistStr))

simplelist_lod = [
    {clas: "^app1$"},
    {clas: "^app2$"},
    {clas: "^app3$"},
]

# slow/heavy, recursive calls to matchProps() with args from each dict
keymap("app", {
    # some remaps
}, when = matchProps(lst=simplelist_lod))

But when you have multiple attributes of the context object that you want to match for multiple different apps at the same time, you have to use a "list of dicts" and pass it to matchProps() with the lst or not_lst argument name.

Evaluating the heavier operations is often avoidable by placing simpler conditions in front of them. Python will "short circuit" if any part of an and string of conditions is considered falsy, so if we use something like the cnfg.screen_has_focus class instance variable (a boolean) and the Synergy log watcher sets it to False, every keymap or modmap conditional that includes that condition as the first condition will simple be inactive. Until the Synergy log watcher sees focus coming back to the screen. (This variable doesn't have to be used everywhere, because matchProps() also checks it internally.)

It's also possible to keep a keymap from entering memory at all, if it should only exist under certain circumstances. This trick is now used extensively for keymaps that are specific to a desktop environment, which shouldn't be created if the desktop environment doesn't match at startup. For instance this keymap would only exist if you actually start the keymapper in Budgie desktop environment:

if DESKTOP_ENV == 'budgie':
    keymap("GenTerms overrides: Budgie", {
        C("LC-Right"):              [bind,C("C-Alt-Right")],        # Default SL - Change workspace (budgie)
        C("LC-Left"):               [bind,C("C-Alt-Left")],         # Default SL - Change workspace (budgie)
    }, when = lambda ctx:
        cnfg.screen_has_focus and
        matchProps(clas=termStr)(ctx)
    )

Acknowledgements

Nothing Toshy does with its config, or special features of the keymapper, would have been possible without the initial work done by Ben Reaves (the Kinto project) and Josh Goebel (the keymapper keyszer, forked from xkeysnail).

I now use a fork of keyszer that I named xwaykeyz, that incorporates all the Wayland support and some other fixes.

Everything is fully open-source.