├── imes
└── remapper
├── .gitignore
├── config.ini.sample
├── remapper
├── main.js
├── manifest.json
├── keymap.js
├── preamble.js
└── engine.js
├── package.json
├── LICENSE
├── hijack.js
├── wscript
└── README.md
/imes/remapper:
--------------------------------------------------------------------------------
1 | ../remapper
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *~
2 | /build/
3 | /config.ini
4 | /imes/*
5 | !/imes/remapper
6 | .lock*
7 | /node_modules/
8 | /waf
9 | /waf-*
10 | .waf*
11 |
--------------------------------------------------------------------------------
/config.ini.sample:
--------------------------------------------------------------------------------
1 | [us_emacs]
2 | name = US x emacs
3 | description = US keyboard with emacs-like cursor movement
4 | language = en-US
5 | layout = us
6 | fallback_imes =
7 | options_page =
8 |
--------------------------------------------------------------------------------
/remapper/main.js:
--------------------------------------------------------------------------------
1 | (function() {
2 | var remapper = new Remapper.Engine(keymap);
3 | Remapper.hijack.onFocus.addListener(remapper.handleFocus.bind(remapper));
4 | Remapper.hijack.onKeyEvent.addListener(remapper.handleKeyEvent.bind(remapper));
5 | })();
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "chromeos-key-remapper",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "background.js",
6 | "dependencies": {
7 | "jscodeshift": "^0.14.0"
8 | },
9 | "devDependencies": {},
10 | "scripts": {
11 | "test": "echo \"Error: no test specified\" && exit 1"
12 | },
13 | "repository": {
14 | "type": "git",
15 | "url": "git+https://github.com/ento/chromeos-key-remapper.git"
16 | },
17 | "author": "",
18 | "license": "ISC",
19 | "bugs": {
20 | "url": "https://github.com/ento/chromeos-key-remapper/issues"
21 | },
22 | "homepage": "https://github.com/ento/chromeos-key-remapper#readme"
23 | }
24 |
--------------------------------------------------------------------------------
/remapper/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "US keyboard x emacs",
3 | "version": "1.0",
4 | "manifest_version": 2,
5 | "description": "US keyboard with emacs-like cursor movement",
6 | "background": {
7 | "scripts": [
8 | "preamble.js",
9 | "engine.js",
10 | "keymap.js",
11 | "main.js"
12 | ]
13 | },
14 | "permissions": [
15 | "input", "tabs"
16 | ],
17 | "input_components": [
18 | {
19 | "name": "US x emacs",
20 | "type": "ime",
21 | "id": "io.github.ento.cros_key_remapper",
22 | "description": "US keyboard with emacs-like cursor movement",
23 | "language": "en-US",
24 | "layouts": ["us"]
25 | }
26 | ]
27 | }
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Marica Odagaki
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/remapper/keymap.js:
--------------------------------------------------------------------------------
1 | // bindings for emacs-like cursor movements.
2 | // variable name must match what's referenced in main.js.
3 | // TODO: better documentation on what values are accepted.
4 | const keymap = [
5 | {'match': 'C-a', 'emit': ['Home']}, // cursor: beginning of line
6 | {'match': 'C-e', 'emit': ['End']}, // cursor: end of line
7 | {'match': 'C-f', 'emit': ['ArrowRight']}, // cursor: forward one character
8 | {'match': 'C-b', 'emit': ['ArrowLeft']}, // cursor: back one character
9 | {'match': 'C-p', 'emit': ['ArrowUp']}, // cursor: previous line
10 | {'match': 'C-n', 'emit': ['ArrowDown']}, // cursor: next line
11 | {'match': 'C-k', 'emit': ['S-End', 'Backspace']}, // cursor: cut to end of line
12 | {'match': 'C-h', 'emit': ['Backspace']}, // cursor: backspace
13 | {'match': 'C-d', 'emit': ['Delete']}, // cursor: delete one char
14 | {'match': 'M-a', 'emit': ['C-KeyA']}, // C-a replacement: for select all
15 | {'match': 'M-b', 'emit': ['C-KeyB']}, // C-b replacement: for boldening text on paper
16 | {'match': 'M-n', 'emit': ['C-KeyN']}, // C-n replacement: for opening a new window
17 | {'match': 'M-k', 'emit': ['C-KeyK']} // C-k replacement: for Slack channel switcher
18 | ];
19 |
--------------------------------------------------------------------------------
/remapper/preamble.js:
--------------------------------------------------------------------------------
1 | const Remapper = {};
2 |
3 | // Acts as a middleman that accepts an chrome.input.ime event and
4 | // feeds it to registered event listeners. First listener to register
5 | // gets to handle the event first.
6 | Remapper.EventHandler = function EventHandler() {
7 | this.listeners = []
8 | };
9 |
10 | Remapper.EventHandler.prototype.addListener = function(fn) {
11 | this.listeners.push(fn)
12 | };
13 |
14 | Remapper.EventHandler.prototype.handleEvent = function() {
15 | let handled = false;
16 | for (let listener of this.listeners) {
17 | handled = listener.apply(null, arguments);
18 | if (handled) break;
19 | }
20 | return handled;
21 | };
22 |
23 | // Array of events to hijack.
24 | // see: https://developer.chrome.com/extensions/input_ime
25 | Remapper.events = [
26 | 'onActivate',
27 | 'onDeactivated',
28 | 'onFocus',
29 | 'onBlur',
30 | 'onInputContextUpdate',
31 | 'onKeyEvent',
32 | 'onCandidateClicked',
33 | 'onMenuItemActivated',
34 | 'onSurroundingTextChanged',
35 | 'onReset'
36 | // 'onCompositionBoundsChanged' // appears to be private
37 | ];
38 |
39 | // Name must match what's in hijack.js
40 | Remapper.hijack = {};
41 |
42 | Remapper.events.forEach(function(event) {
43 | const handler = new Remapper.EventHandler()
44 | Remapper.hijack[event] = handler;
45 | // The entire plot hinges on this `addListener` call not being
46 | // picked up by hijack.js, because this extension is just another
47 | // ime to be composed together with fallback imes.
48 | chrome.input.ime[event].addListener(handler.handleEvent.bind(handler));
49 | })
50 |
--------------------------------------------------------------------------------
/hijack.js:
--------------------------------------------------------------------------------
1 | /* Rewrite calls to chrome.input.ime.onX.addListener as
2 | Remapper.hijack.onX.addListener.
3 | */
4 |
5 | var fs = require('fs');
6 | const jscodeshift = require('jscodeshift');
7 |
8 | function baseFilter() {
9 | return {
10 | callee: {
11 | property: {name: "addListener"},
12 | object: {
13 | object: {
14 | property: {name: "ime"},
15 | object: {
16 | property: {name: "input"},
17 | object: {
18 | name: "chrome"
19 | }
20 | }
21 | }
22 | }
23 | }
24 | };
25 | }
26 |
27 | // filter for calls like `ime.onKeyData`
28 | function propertyMemberFilter() {
29 | const filter = baseFilter();
30 | filter.callee.object.computed = false; // ignore dynamic member lookup like `ime[eventName]`
31 | filter.callee.object.property = {type: "Identifier"};
32 | return filter;
33 | }
34 |
35 | function propertyMemberEvent(path) {
36 | return path.node.callee.object.property.name;
37 | }
38 |
39 | // filter for calls like `ime["onKeyData"]`
40 | function literalMemberFilter() {
41 | const filter = baseFilter();
42 | filter.callee.object.computed = true;
43 | filter.callee.object.property = {type: "Literal"};
44 | return filter;
45 | }
46 |
47 | function literalMemberEvent(path) {
48 | return path.node.callee.object.property.value;
49 | }
50 |
51 | function rewrite(j, getEventName) {
52 | return function(path) {
53 | {
54 | j(path).replaceWith(
55 | j.callExpression(j.memberExpression(
56 | j.memberExpression(
57 | j.memberExpression(
58 | j.identifier("Remapper"),
59 | j.identifier("hijack"), // onX
60 | false),
61 | j.identifier(getEventName(path)), // onX
62 | false),
63 | j.identifier(path.node.callee.property.name), // addListener
64 | false),
65 | path.node.arguments)
66 | );
67 | }
68 | };
69 | }
70 |
71 | function transformer(file, api) {
72 | const j = api.jscodeshift;
73 | const root = j(file.source);
74 | root.find(j.CallExpression, propertyMemberFilter())
75 | .forEach(rewrite(j, propertyMemberEvent));
76 | root.find(j.CallExpression, literalMemberFilter())
77 | .forEach(rewrite(j, literalMemberEvent))
78 | return root.toSource();
79 | }
80 |
81 | if (process.argv.length < 4) {
82 | console.error("you should be invoking waf instead of this script, but if you really need to know:");
83 | console.error('usage: node hijack.js input_path output_path');
84 | process.exit(1);
85 | }
86 |
87 | // the jscodeshift command rewrites files in place, and waf doesn't
88 | // like it when the input and output are the same file. so this is a
89 | // simple wrapper around the jscodeshift transformer function that
90 | // reads JS code from the specified input path, transforms it, and
91 | // writes to the specified output path.
92 | fs.readFile(process.argv[2], {encoding: 'utf8'}, function(err, source) {
93 | if (err) {
94 | console.error(err);
95 | process.exit(1);
96 | }
97 | var result = transformer({source: source}, {jscodeshift: jscodeshift});
98 | fs.writeFile(process.argv[3], result, function(err) {
99 | if (err) {
100 | console.error(err);
101 | process.exit(1);
102 | }
103 | });
104 | })
105 |
--------------------------------------------------------------------------------
/remapper/engine.js:
--------------------------------------------------------------------------------
1 | Remapper.Engine = function (keymap) {
2 | var contextId = -1;
3 | var lastFocusedWindowUrl = null;
4 | const debug = false;
5 |
6 | const urlBlacklist = [
7 | 'chrome-extension://pnhechapfaindjhompbnflcldabbghjo/html/crosh.html'
8 | ];
9 |
10 | const nullKeyData = {
11 | 'altKey': false,
12 | 'ctrlKey': false,
13 | 'shiftKey': false,
14 | 'key': '',
15 | 'code': ''
16 | };
17 |
18 | const sequencePrefixToKeyDataAttribute = {
19 | 'C-': 'ctrlKey',
20 | 'S-': 'shiftKey',
21 | 'M-': 'altKey'
22 | }
23 |
24 | function keyDataToSequenceString(keyData) {
25 | var sequence = '';
26 | if (keyData.ctrlKey) {
27 | sequence += 'C-';
28 | }
29 | if (keyData.shiftKey) {
30 | sequence += 'S-';
31 | }
32 | if (keyData.altKey) {
33 | sequence += 'M-';
34 | }
35 | sequence += keyData.key;
36 | return sequence;
37 | }
38 |
39 | function sequenceStringToKeyData(sequence) {
40 | var keyData = {};
41 | sequence.split(/(C-|M-|S-)/).forEach(function(part) {
42 | if (part.length == 0) {
43 | return;
44 | }
45 | var booleanAttribute = sequencePrefixToKeyDataAttribute[part];
46 | if (booleanAttribute) {
47 | keyData[booleanAttribute] = true;
48 | return;
49 | }
50 | // TODO: validate part is valid as code
51 | // Note: allegedly, only the `code` matters when using the `sendKeyEvents` API.
52 | keyData.code = part;
53 | });
54 | return keyData;
55 | }
56 |
57 | // grab the last focused window's URL for blacklisting. note that there will
58 | // be a delay due to the API being async.
59 | this.handleFocus = function(context) {
60 | contextId = context.contextID;
61 | chrome.windows.getLastFocused({
62 | populate: true,
63 | windowTypes: ['popup', 'normal', 'panel', 'app', 'devtools']
64 | }, function(window) {
65 | if (window && window.tabs.length > 0) {
66 | lastFocusedWindowUrl = window.tabs[0].url;
67 | }
68 | });
69 | }
70 |
71 | this.handleKeyEvent = function(engineID, keyData) {
72 | if (keyData.type === "keydown") {
73 | if (debug) {
74 | console.log(keyData.type, keyData.key, keyData.code, keyData);
75 | }
76 | }
77 |
78 | if (keyData.extensionId && (keyData.extensionId === chrome.runtime.id)) {
79 | // already remapped, pass it through
80 | return false;
81 | }
82 |
83 | if (lastFocusedWindowUrl && urlBlacklist.indexOf(lastFocusedWindowUrl) !== -1) {
84 | // don't remap in blacklisted windows
85 | return false;
86 | }
87 |
88 | var handled = false;
89 |
90 | if (keyData.type === "keydown") {
91 | var encodedSequence = keyDataToSequenceString(keyData);
92 |
93 | // TODO: convert keymap to an object of {match: decodedSequences} for speed
94 | var activeMapping = keymap.find(function(candidate) {
95 | return encodedSequence === candidate.match;
96 | });
97 |
98 | if (activeMapping) {
99 | var newKeyData = activeMapping.emit.map(function(sequence) {
100 | var mappedKeyData = sequenceStringToKeyData(sequence);
101 | return Object.assign({}, keyData, nullKeyData, mappedKeyData);
102 | });
103 | chrome.input.ime.sendKeyEvents({"contextID": contextId, "keyData": newKeyData});
104 | handled = true;
105 | }
106 | }
107 |
108 | return handled;
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/wscript:
--------------------------------------------------------------------------------
1 | import configparser
2 | from collections import OrderedDict
3 |
4 | from waflib import Task
5 | from waflib.TaskGen import feature, after_method
6 |
7 |
8 | top = '.'
9 | out = 'build'
10 |
11 | required_configs = [
12 | 'name',
13 | 'description',
14 | 'language',
15 | 'layout',
16 | 'fallback_imes',
17 | 'options_page',
18 | ]
19 |
20 |
21 | def configure(ctx):
22 | ctx.find_program('jscodeshift')
23 |
24 |
25 | def build(ctx):
26 | config = _read_config()
27 | for name in config:
28 | _build_ime(ctx, name, config[name])
29 |
30 |
31 | def _read_config():
32 | parser = configparser.ConfigParser()
33 | parser.read('config.ini')
34 | config = OrderedDict()
35 | for name in parser.sections():
36 | spec = {}
37 | for prop in required_configs:
38 | # TODO: more human-friendly error
39 | spec[prop] = parser[name][prop]
40 | spec['fallback_imes'] = [ime_name.strip()
41 | for ime_name in spec['fallback_imes'].split(',')
42 | if len(ime_name.strip()) > 0]
43 | config[name] = spec
44 | return config
45 |
46 |
47 | def _build_ime(ctx, name, spec):
48 | out = ctx.bldnode.find_or_declare(name)
49 |
50 | # copy / transform sources
51 |
52 | imes_root = ctx.path.find_dir('imes')
53 | transformer = ctx.path.find_node('hijack.js')
54 |
55 | # remapper must be the first in stack in order for hijacking to work.
56 | ime_stack = ['remapper'] + spec['fallback_imes']
57 | for ime_name in ime_stack:
58 | extension_root = imes_root.find_dir(ime_name)
59 | for extension_file in extension_root.ant_glob('**/*'):
60 | is_javascript = extension_file.suffix() == '.js'
61 | source = extension_file
62 | target = out.find_or_declare(extension_file.path_from(imes_root))
63 | if is_javascript:
64 | jscodeshift_env = ctx.env.derive()
65 | jscodeshift_env.TRANSFORMER = transformer.abspath()
66 | jscodeshift_task = jscodeshift(env=jscodeshift_env)
67 | jscodeshift_task.set_inputs(source)
68 | jscodeshift_task.set_outputs(target)
69 | ctx.add_to_group(jscodeshift_task)
70 | ctx.add_manual_dependency(source, transformer)
71 | else:
72 | ctx(features='subst',
73 | is_copy=True,
74 | source=source,
75 | target=target)
76 |
77 | # build manifest
78 |
79 | manifests = [out.find_or_declare(ime_name).find_or_declare('manifest.json')
80 | for ime_name in ime_stack]
81 | manifest_env = ctx.env.derive()
82 | manifest_env.identifier = name
83 | manifest_env.name = spec['name']
84 | manifest_env.description = spec['description']
85 | manifest_env.language = spec['language']
86 | manifest_env.layout = spec['layout']
87 | manifest_env.options_page = spec['options_page']
88 | manifest_task = manifest(env=manifest_env)
89 | manifest_task.set_inputs(manifests)
90 | manifest_task.set_outputs(out.find_or_declare('manifest.json'))
91 | ctx.add_to_group(manifest_task)
92 |
93 |
94 | class jscodeshift(Task.Task):
95 | run_str = 'node ${TRANSFORMER} ${SRC} ${TGT}'
96 |
97 |
98 | class manifest(Task.Task):
99 | '''
100 | Builds a manifest JSON by collecting background scripts and permissions
101 | from input files and reading the name, description, language, and layout
102 | from `self.env`.
103 | '''
104 |
105 | def run(self):
106 | target = self.outputs[0]
107 | out = target.parent
108 | submanifests = OrderedDict([(path, path.read_json()) for path in self.inputs])
109 |
110 | scripts = [path.parent.find_or_declare(script).path_from(out)
111 | for path, submanifest in submanifests.items()
112 | for script in submanifest['background']['scripts']]
113 |
114 | permissions = [permission
115 | for path, submanifest in submanifests.items()
116 | for permission in submanifest['permissions']]
117 |
118 | input_component = {
119 | "name": self.env.name,
120 | "type": "ime",
121 | "id": "io.github.ento.cros_key_remapper." + self.env.identifier,
122 | "description": self.env.description,
123 | "language": self.env.language,
124 | "layouts": [self.env.layout],
125 | }
126 | manifest = {
127 | "name": self.env.name,
128 | "version": "1.0",
129 | "manifest_version": 2,
130 | "description": self.env.description,
131 | "background": {
132 | "scripts": scripts,
133 | },
134 | "permissions": list(set(permissions)),
135 | "input_components": [
136 | input_component
137 | ]
138 | }
139 | if self.env.options_page:
140 | manifest['options_page'] = self.env.options_page
141 | target.write_json(manifest)
142 |
143 |
144 | def imes(ctx):
145 | imes_root = ctx.path.find_dir('imes')
146 | for extension_root in imes.ant_glob('*', src=False, dir=True):
147 | print(extension_root.path_from(imes_root))
148 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # chromeos-key-remapper
2 |
3 | This repo contains:
4 |
5 | 1. An unpublished Chrome OS IME that lets you use emacs-like cursor movement
6 | keys with a US English keyboard. `C-a` and `C-k`, to name a few.
7 | 2. Tooling to build a custom IME that combines the remapper engine and other 3rd party IMEs.
8 |
9 | ## Limitations
10 |
11 | - All the combined IMEs share the same JavaScript scope. Name collision
12 | can happen.
13 | - The options page of the custom IME can only display the options page of a
14 | single IME.
15 | - Keys can be only remapped to other key combinations. i.e. can't do things
16 | that the OS already supports through exiting key combos.
17 | - Only a single key combo can be mapped: no support for sequence of keys.
18 |
19 | ## Prerequisites / assumptions
20 |
21 | For the premade IME:
22 |
23 | - You don't want to remap when you're in crosh window
24 |
25 | Additionally, for the make-your-own route:
26 |
27 | - python (I use 3.x; 2.x _probably_ works)
28 | - [`waf` command](https://waf.io/book/#_download_and_installation)
29 | - `waf*` is gitignored in this repo; I have it downloaded to the root of my local clone
30 | - [`jscodeshift` command](https://github.com/facebook/jscodeshift)
31 | - `npm install` in this repo and add `./node_module/.bin` to `$PATH` when invoking `waf`
32 | - or: `npm install -g jscodeshift`
33 |
34 | ## How to install
35 |
36 | First, download this repo as a zip file and unpack it or clone the repo.
37 | Chrome OS needs to have access to the file system where your local copy resides.
38 |
39 | Go to chrome://extensions and enable developer mode.
40 |
41 | ### Using the premade IME
42 |
43 | In chrome://extensions, click the "Load unpacked extension..." button
44 | and pick the `remapper` directory in your local copy of the repo.
45 |
46 | Open Settings, then search for "manage input methods." Click the highlighted row
47 | with that label. There should be a row named "US x emacs"; check to enable it.
48 |
49 | Now, pressing Ctrl-Shift-Space will cycle through all the
50 | IMEs that are enabled. Hit Ctrl-Space to switch back and forth
51 | between the previously selected IME.
52 |
53 | There's an indicator next to the notification indicator that shows the active
54 | IME: make sure you've activated the IME you just installed and try out
55 | a few bindings like `C-f`, `C-b` in a text field.
56 |
57 | See [`remapper/keymap.js`](./remapper/keymap.js) for the keybindings.
58 | If you want different bindings, edit this file and reload the extension.
59 |
60 | ### Making your own with a different language and/or layout
61 |
62 | You need to create a `config.ini` file. `config.ini.sample` is a good
63 | starting point:
64 |
65 | ```sh
66 | cp config.ini.sample config.ini
67 | ```
68 |
69 | The config file can contain multiple sections. Each section, when
70 | built, will result in a directory under `./build` that contains an
71 | IME extension. An IME extension can potentially hold multiple
72 | IMEs; however, this tooling only supports a single IME per extension.
73 |
74 | Here's what a section looks like:
75 |
76 | ```ini
77 | [us_emacs]
78 | name = EN x emacs
79 | description = US keyboard with emacs-like cursor movement
80 | language = en-US
81 | layout = us
82 | fallback_imes =
83 | options_page =
84 | ```
85 |
86 | `[us_emacs]` is the section name, which will become the name of the directory.
87 |
88 | `name` and `description` will become the name and description of both the extension
89 | and the IME you see in Settings.
90 |
91 | `language` and `layout` dictate which language and layout the IME will be for.
92 | Change these to make an IME for your preferred language and layout. I'm not sure
93 | what values are accepted here. The [extra keyboards repo][extra-keyboard] may
94 | be a good resource.
95 |
96 | `fallback_imes` and `options_page` are irrelevant to what we're doing now; we'll
97 | come back to it later.
98 |
99 | To build an IME out of this config file, run `waf`:
100 |
101 | ```sh
102 | python waf configure build
103 | ```
104 |
105 | This should create a directory `./build/us_emacs/`, which you can install
106 | like the premade one above.
107 |
108 | ### Making your own by combining other IMEs
109 |
110 | The basic steps are the same as the above: create `config.ini` and run `waf`.
111 |
112 | This time, we actually make use of `fallback_imes` and `options_page`.
113 |
114 | `fallback_imes` is a comma-separated list of directory names you put
115 | in `./imes`. Said directories must contain an IME extension. All
116 | files in the directory will be copied to
117 | `build/[config_section_name]/[fallback_ime_name]/`, with `.js` files
118 | getting special treatment to make all this work.
119 |
120 | `options_page` should be the path to the options page to use, relative
121 | to the built extension's root: `[fallback_ime_name]/options.html`, for example.
122 |
123 | How you place an IME extension under `./imes` is up to you. You could
124 | clone a repo there, or symlink to a directory outside the repo.
125 |
126 | Let's see an example of how all this fits together.
127 |
128 | I want to use my emacs keybindings on top of [Chrome SKK][skk], a Japanese
129 | IME, so here's what I do:
130 |
131 | ```
132 | chrome-skk/
133 | chrome-key-remapper/
134 | config.ini
135 | imes/
136 | skk -> ../../chrome-skk/extension
137 | remapper/
138 | ..
139 | ```
140 |
141 | With a config like:
142 |
143 | ```
144 | [skk_remapped]
145 | name = SKK x emacs
146 | description = SKK with emacs-like cursor movement
147 | language = ja
148 | layout = us
149 | fallback_imes = skk
150 | options_page = skk/options.html
151 | ```
152 |
153 | When I invoke `python waf configure build`, an IME extension gets built in `./build/`:
154 |
155 | ```
156 | chrome-key-remapper/
157 | build/
158 | skk_remapped/
159 | manifest.json
160 | remapper/
161 | main.js
162 | ..
163 | skk/
164 | main.js
165 | ..
166 | ```
167 |
168 | With this hybrid IME enabled and active, any key event not handled by
169 | the remapper will get passed onto SKK, enabling me to input Japanese
170 | text while enjoying the familiar emacs-like cursor movement keys.
171 |
172 | [extra-keyboard]: https://github.com/google/extra-keyboards-for-chrome-os
173 | [skk]: https://github.com/jmuk/chrome-skk
174 |
--------------------------------------------------------------------------------