├── 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 | --------------------------------------------------------------------------------