wmii+node.js, Part 2: Keyboard Events

This post is part of a series.

Previous post: << Event Handling

In my last post, I set up a basic event-handling framework; in this one, I’m going to start using it to control wmii. Most of wmii’s mouse controls are built-in, but the keyboard controls are handled by the configuration script. wmii emits a “Key” event when a key is pressed, but only if that key is already bound. wmii only binds the keys listed in its /keys file; all key combinations listed in that file will be intercepted by wmii, and other programs will not be able to receive them.

Of course, the /keys file is, like every other part of wmii’s virtual filesystem, modifiable at runtime. So it’s possible to change the keybindings while wmii is running. This opens up the possibility of key modes, something that is used by other tiling WMs (like i3) to allow for easy keyboard-only resizing of tiled windows. With a resize mode, we could just press Mod4+r, use the arrow keys to adjust a window’s size, then press ESC or Mod4+r again to return to the default mode.

(For readers who are new to tiling window managers, Mod4 is another name for the Super or Windows key. Most wmii keybindings use this key as a modifier, since very few other programs use it.)

Here is a simple Node module that handles setting key bindings, reading key events, and switching key modes:

wmii_keys.js

// wmii keybindings and keyboard event module
// Adam R. Nelson
// August 2013
var wmiir = require('./wmiir.js');
var events = require('./wmii_events.js');
var globalKeys = {};
var modeKeys = {};
var currentMode = null;
var modes = {};
function rewriteKeyFile(callback) {
var keyData = Object.keys(modeKeys).concat(Object.keys(globalKeys)).join('\n');
wmiir.write('/keys', keyData, callback);
}
events.on('Key', function(key) {
var handler = modeKeys[key] || globalKeys[key];
if (handler) handler();
});
// Binds the key `key` to the function `keyAction`, globally.
// If `callback` is passed, it will be called after the keybinding is written
// to wmii's filesystem.
exports.bindKey = function(key, keyAction, callback) {
globalKeys[key] = keyAction;
rewriteKeyFile(callback);
};
// Takes an object (`keyBindings`) that maps key names to callback functions.
// Each key will be bound, globally, to its value (which should be a function).
// If `callback` is passed, it will be called after all keybindings are written
// to wmii's filesystem.
exports.bindKeys = function(keyBindings, callback) {
for (var key in keyBindings) {
globalKeys[key] = keyBindings[key];
}
rewriteKeyFile(callback);
};
// Removes the global keybinding for `key`, if one exists. Keybindings
// belonging to modes will be unaffected.
// If `callback` is passed, it will be called after the keybinding changes are
// written to wmii's filesystem.
exports.unbindKey = function(key, callback) {
globalKeys[key] = undefined;
rewriteKeyFile(callback);
};
// Removes all global keybindings. Keybindings belonging to modes will be
// unaffected.
// If `callback` is passed, it will be called after the keybinding changes are
// written to wmii's filesystem.
exports.unbindAll = function(callback) {
globalKeys = {};
rewriteKeyFile(callback);
};
// Defines a mode, with the name `modeName`. `keyBindings` should be an object
// that maps key names to callback functions. Within the new mode, all of the
// keys in `keyBindings` will be bound to their valies (which should be
// functions).
// If a mode named `modeName` already exists, this mode will replace it.
exports.defineMode = function(modeName, keyBindings) {
modes[modeName] = keyBindings;
};
// Returns the name of the currently-active mode.
exports.getMode = function() {
return currentMode;
};
// Sets the current mode to `modeName`, if a mode with that name exists.
// Unbinds all keys bound in the current mode, and binds all keys belonging to
// the new mode.
// Emits a 'ModeChanged' event if the mode was changed successfully, or an
// 'Error' event if it was not.
// If `callback` is passed, it will be called after the keybinding changes are
// written to wmii's filesystem. It will be passed one argument, a boolean,
// indicating whether the mode change succeeded.
exports.setMode = function(modeName, callback) {
try {
var mode = modes[modeName];
if (!mode) {
events.emit('Error', 'No such mode: ' + modeName);
if (callback) callback(false);
return;
}
currentMode = modeName;
modeKeys = mode;
rewriteKeyFile(function(err) {
if (err) {
events.emit('Error', err.message || err);
} else {
events.emit('ModeChanged', modeName);
}
if (callback) callback(true);
});
} catch (ex) {
console.log(ex);
}
};
view raw wmii_keys.js hosted with ❤ by GitHub

Simply require-ing this file and binding some keys or creating and setting a mode will automatically handle wmii key events. With key events working, we have almost everything we need to get a bare-bones wmii environment running. Before we do, however, let’s set up a few more basic functions and organize the project files in a sensible way.

 

dialog.js

One thing that will be very useful in this project is error reporting. Console messages don’t work very well for a window manager, because the console that launched it is never visible. The default wmii config uses dialog boxes to communicate with the user, but it uses xmessage to generate them… xmessage is ugly, and its scrollbar is confusing. I’m going to use zenity instead. (Install it with your package manager if you don’t have it.) This module should take care of all of our dialog box needs:

// zenity dialog box functions
// Adam R. Nelson
// August 2013
var spawn = require('child_process').spawn;
function appendSettings(args, settings) {
if (settings) {
if (settings.ok) {
args.push('--ok-label');
args.push(settings.ok);
}
if (settings.cancel) {
args.push('--cancel-label');
args.push(settings.cancel);
}
if (settings.title) {
args.push('--title');
args.push(settings.title);
}
}
}
// Displays a Zenity error dialog, with the text `message`. If `settings` is
// provided, it should be an object which may contain these keys:
// - title: The title of the dialog box.
// - ok: The caption of the OK button.
// If `callback` is provided, it will be called after the user clicks OK.
exports.error = function(message, settings, callback) {
var args = ['--error', '--text', message];
appendSettings(args, settings);
var child = spawn('zenity', args);
child.on('exit', function() {if (callback) callback();});
};
// Displays a Zenity question dialog, with the text `message`. If `settings` is
// provided, it should be an object which may contain these keys:
// - title: The title of the dialog box.
// - ok: The caption of the OK button.
// - cancel: The caption of the Cancel button.
// If `callback` is provided, it will be called after the user clicks a button.
// It will be passed a single argument, a boolean, which will be true if the
// user clicked OK and false if the user clicked Cancel.
exports.question = function(message, settings, callback) {
var args = ['--question', '--text', message];
appendSettings(args, settings);
var child = spawn('zenity', args);
child.on('exit', function(code) {
if (callback) callback(code === 0);
});
};
// Displays a Zenity information dialog, with the text `message`. If `settings`
// is provided, it should be an object which may contain these keys:
// - title: The title of the dialog box.
// - ok: The caption of the OK button.
// If `callback` is provided, it will be called after the user clicks OK.
exports.info = function(message, settings, callback) {
var args = ['--info', '--text', message];
appendSettings(args, settings);
var child = spawn('zenity', args);
child.on('exit', function() {if (callback) callback();});
};
view raw dialog.js hosted with ❤ by GitHub

 

Folder Structure

In order to make a working wmii config out of these scripts, they need to be in the ~/.wmii-hg folder, and they need to be called from an executable script named wmiirc. Below is what the ~/.wmii-hg folder structure should look like, with files that I haven’t written yet prefixed with an asterisk (*):

.wmii-hg/
  lib/
    dialog.js
    wmii_events.js
    *wmii.js
    wmii_keys.js
    wmiir.js
  *wmiirc.js
  *wmiirc

 

The missing * files are core files, which will be very complex by the time this config is done. For now, I will write skeleton versions of them that will allow me to test the key-event-handling code.

 

wmii.js

This file is the core of the wmii config. It should handle core events (like errors), displaying menus, and providing an interface to some of the wmii filesystem’s core files (like /ctl, /rules, and /colrules). For now, I will implement error messages and two features of the standard wmii config: the actions menu (Mod4+a) and the programs menu (Mod4+p).

// wmii core Module
// Adam R. Nelson
// August 2013
var wmiir = require('./wmiir.js');
var events = require('./wmii_events.js');
var keys = require('./wmii_keys.js');
var dialog = require('./dialog.js');
var spawn = require('child_process').spawn;
var actions = {};
var programs = [];
function indexPrograms() {
try {
var proglist = "";
var child = spawn('wmiir', ['proglist'].concat(process.env.PATH.split(':')));
child.stdout.on('data', function(chunk) {
proglist += chunk;
});
child.on('exit', function() {
programs = proglist.split('\n');
});
} catch (e) {
dialog.error('Failed to index programs: ' + e.message);
}
}
// Executes a shell command string.
function execString(cmdStr) {
wmiir.setsid('sh', '-c', cmdStr);
}
exports.events = events;
exports.keys = keys;
// Trigger preconfigured actions with the "Action" event.
events.on('Action', function(action) {
var handler = actions[action];
if (handler) handler.apply(this, Array.prototype.slice.call(arguments, 1));
else events.emit('Error', 'No such action: ' + action);
});
// This flag is used to prevent infinite loops from spawning too many error
// messages. Only one error message may be on screen at a time.
var errorMessageVisible = false;
// Display an error message when the "Error" event occurs.
events.on('Error', function(/* varargs */) {
var message = Array.prototype.slice.apply(arguments).join(' ');
console.log('Error occurred!');
console.log(message);
if (!errorMessageVisible) {
errorMessageVisible = true;
dialog.error(message, {title: 'wmii error'}, function() {
errorMessageVisible = false;
});
}
});
// Displays a wimenu, containing the items in the array `items` and displaying
// the prompt `prompt`, and, once the user selects an item, calls `callback`
// with the selected item as the first argument.
function menu(items, prompt, callback) {
var child = spawn('wimenu', ['-p', prompt]);
child.on('error', function(err) {
events.emit('Error', err.message || err);
});
var stdout = "";
child.stdout.on('data', function(chunk) {
stdout += chunk.toString();
});
child.on('exit', function(code) {
if (code === 0 && callback)
callback(stdout);
});
child.stdin.end(items.sort().join('\n'));
return child;
}
exports.menu = menu;
// Defines a new action, with the name `name` and the callback function
// `action`. This action will be listed in the actions menu.
exports.defineAction = function(name, action) {
actions[name] = action;
};
// Displays the actions menu.
exports.actionsMenu = function() {
menu(Object.keys(actions), 'Action>', function(action) {
events.emit('Action', action);
});
};
// Displays a menu of all executable programs in the user's PATH, and executes
// whatever command is entered into the menu.
exports.programsMenu = function() {
menu(programs, 'Run>', function(command) {
execString(command);
});
};
// Index the programs menu before starting wmii.
indexPrograms();
view raw wmii.1.js hosted with ❤ by GitHub

 

wmiirc.js

This file is the real wmiirc file; it’s the file that users of the config would edit if they wanted to change their keybindings, menus, etc. For now, I will define one mode (“default”) and some standard wmii keybindings, as well as a single action (“quit”) that can be accessed through the Mod4-a action menu.

var dialog = require('./lib/dialog.js');
var wmii = require('./lib/wmii.js');
var wmiir = require('./lib/wmiir.js');
var modkey = 'Mod4';
var terminal = 'urxvt';
function attachModKey(mode) {
var newMode = {};
for (var key in mode) {
if (key.indexOf('+' === 0))
newMode[modkey + '-' + key.slice(1)] = mode[key];
else
newMode[key] = mode[key];
}
return newMode;
}
function tagctl(command) {
return function() {
wmiir.write('/tag/sel/ctl', command);
};
}
function clientctl(command) {
return function() {
wmiir.write('/tag/client/ctl', command);
};
}
wmii.defineAction('quit', function() {
wmii.events.emit('Exit');
wmiir.write('/ctl', 'quit');
});
// The '+' will be replaced with the modkey in the actual keybindings.
var defaultMode = {
'+Left': tagctl('select left'),
'+Right': tagctl('select right'),
'+Up': tagctl('select up'),
'+Down': tagctl('select down'),
'+space': tagctl('select toggle'),
'+Shift-Left': tagctl('send sel left'),
'+Shift-Right': tagctl('send sel right'),
'+Shift-Up': tagctl('send sel up'),
'+Shift-Down': tagctl('send sel down'),
'+Shift-space': tagctl('send sel toggle'),
'+d': tagctl('colmode sel default-max'),
'+s': tagctl('colmode sel stack-max'),
'+m': tagctl('colmode sel stack+max'),
'+Shift-c': clientctl('kill'),
'+f': clientctl('Fullscreen toggle'),
'+Return': function() {wmiir.setsid(terminal);},
'+p': wmii.programsMenu,
'+a': wmii.actionsMenu
};
wmii.keys.defineMode('default', attachModKey(defaultMode));
wmii.keys.setMode('default');
view raw wmiirc.1.js hosted with ❤ by GitHub

 

wmiirc

wmii expects an executable script with this name, so I’ll write a simple shell script that launches Node. In the future, I may modify it to relaunch Node if/when it crashes.

#!/bin/sh
node ~/.wmii-hg/wmiirc.js
view raw wmiirc.1.sh hosted with ❤ by GitHub

Make sure to set this file as executable with chmod +x wmiirc! If this file is not executable, wmii will ignore it and use its default configuration.

 

Running wmii in Xephyr

The config is now usable, but it doesn’t provide a full wmii environment; it’s still missing tag support, and, if it crashes, it’s easy to get stuck in wmii without a way to exit. The safe way to test it is to use another window manager (I’m going to use icewm), and to start wmii in a nested X server in a window. This can be done using Xephyr.

Running wmii in a window also allows me to view its console output, which will be very useful for debugging. If my config crashes (which it did several times as I was trying to work out bugs in the scripts), I can just insert console.log statements to print debugging information to the console, and use that to diagnose the problem(s).

First, start Xephyr on display 2:

% Xephyr :2

Xephyr-wmii-sshot-1

Then, in another terminal, launch wmii on display 2:

% DISPLAY=:2 wmii

If you remembered to chmod +x wmiirc, and if all of the files are under ~/.wmii-hg with the correct filenames, then you should see an empty tan-colored bar appear on the bottom of the Xephyr window. (If the bar is not empty, and you also get a welcome popup, then wmii is using its default config, which means something is wrong.) You can start urxvt with Mod4+Enter…

Xephyr-wmii-sshot-2

…or open the programs menu with Mod4+p. (Ignore the console debug output in both screenshots; it’s from a different version of the code than what I’ve posted on this blog.)

Xephyr-wmii-sshot-3

Some of these key shortcuts may not work unless Xephyr has captured the keyboard and mouse. To exit wmii, open the Actions menu with Mod4+a, then select quit (the only option). If wmii hangs or crashes, you can use Ctrl-C to stop it from the terminal.

In my next blog post, I will expand on this config by adding support for tags (wmii’s version of virtual desktops), window rules, and context menus.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s