Skip to content

Commit

Permalink
Tap-hold
Browse files Browse the repository at this point in the history
Introduces "tap" and "hold" flags for mappings.

A "tap" mapping triggers on button release if the time it was pressed
is less than the tap-hold threshold (configurable, 200ms by default).

A "hold" mapping triggers after a button is held for the tap-hold
threshold time (and continues to be triggered until the button is
released).

This can be used to assign two different functions to the same button,
for example to make middle button act as the middle button when
clicked and activate a layer when held.

A "tap" mapping can also be sticky.

Bumps the config version to 5.
  • Loading branch information
jfedor2 committed Jan 27, 2023
1 parent fafcd4e commit 2c067c1
Show file tree
Hide file tree
Showing 11 changed files with 350 additions and 106 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ The _layers_ mechanism might sound familiar if you ever used a custom ergo keybo

Layer activating mappings can be sticky.

_Tap-hold_ is another mechanism adopted from the custom ergo keyboard world. It lets you map the same button to different things depending on whether it's _tapped_ (pressed and released quickly) or _held_ (longer than 200ms or whatever you set the threshold to). This allows configurations where a button keeps its primary function when it's clicked, but activates a layer (or a modifier key like Shift) when it's held. Together with the sticky flag, it can also be used to make a button activate a layer permanently when clicked and also temporarily when held. For the last configuration to work properly, create two separate mappings, one with "sticky"+"tap" enabled, and another with "hold" enabled.

Sometimes you want a certain button to send multiple outputs, but not at the same time, but one after another, for example to input a special character by typing something like Alt-0165 or to emulate a double click. You can do that with the _macros_ feature. Each macro is defined as a list of inputs that will be sent one after another. Every input can consist of multiple keys, for example you can have a macro that looks like this: `Left Shift+H, E, L, Nothing, L, O` that will type the string "Hello" or you can have a macro that looks like this: `Left Alt+Numpad 0, Left Alt+Numpad 1, Left Alt+Numpad 6, Left Alt+Numpad 5` that will enter the yen symbol (on some systems). Or, to emulate a double-click, you could define a macro that looks like this: `Left button, Nothing, Left button`. The "Nothing" part is necessary for the computer to register a "button-up" event, otherwise the first macro would type "Helo" and the last one would just work as a single click.

To make a button send a certain macro, add a mapping with that button as input and with "Macro X" as output.
Expand Down
79 changes: 65 additions & 14 deletions config-tool-web/code.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import usages from './usages.js';
import examples from './examples.js';

const REPORT_ID_CONFIG = 100;
const STICKY_FLAG = 0x01;
const STICKY_FLAG = 1 << 0;
const TAP_FLAG = 1 << 1;
const HOLD_FLAG = 1 << 2;
const CONFIG_SIZE = 32;
const CONFIG_VERSION = 4;
const CONFIG_VERSION = 5;
const VENDOR_ID = 0xCAFE;
const PRODUCT_ID = 0xBAF2;
const DEFAULT_PARTIAL_SCROLL_TIMEOUT = 1000000;
const DEFAULT_TAP_HOLD_THRESHOLD = 200000;
const DEFAULT_SCALING = 1000;

const NLAYERS = 4;
Expand Down Expand Up @@ -46,12 +49,15 @@ let config = {
'version': CONFIG_VERSION,
'unmapped_passthrough_layers': [0, 1, 2, 3],
'partial_scroll_timeout': DEFAULT_PARTIAL_SCROLL_TIMEOUT,
'tap_hold_threshold': DEFAULT_TAP_HOLD_THRESHOLD,
'interval_override': 0,
mappings: [{
'source_usage': '0x00000000',
'target_usage': '0x00000000',
'layers': [0],
'sticky': false,
'tap': false,
'hold': false,
'scaling': DEFAULT_SCALING,
}],
macros: [
Expand All @@ -77,6 +83,7 @@ document.addEventListener("DOMContentLoaded", function () {
device_buttons_set_disabled_state(true);

document.getElementById("partial_scroll_timeout_input").addEventListener("change", partial_scroll_timeout_onchange);
document.getElementById("tap_hold_threshold_input").addEventListener("change", tap_hold_threshold_onchange);
for (let i = 0; i < NLAYERS; i++) {
document.getElementById("unmapped_passthrough_checkbox" + i).addEventListener("change", unmapped_passthrough_onchange);
}
Expand Down Expand Up @@ -131,13 +138,14 @@ async function load_from_device() {

try {
await send_feature_command(GET_CONFIG);
const [config_version, flags, partial_scroll_timeout, mapping_count, our_usage_count, their_usage_count, interval_override] =
await read_config_feature([UINT8, UINT8, UINT32, UINT32, UINT32, UINT32, UINT8]);
const [config_version, flags, partial_scroll_timeout, mapping_count, our_usage_count, their_usage_count, interval_override, tap_hold_threshold] =
await read_config_feature([UINT8, UINT8, UINT32, UINT32, UINT32, UINT32, UINT8, UINT32]);
check_received_version(config_version);

config['version'] = config_version;
config['unmapped_passthrough_layers'] = mask_to_layer_list(flags & ((1 << NLAYERS) - 1));
config['partial_scroll_timeout'] = partial_scroll_timeout;
config['tap_hold_threshold'] = tap_hold_threshold;
config['interval_override'] = interval_override;
config['mappings'] = [];

Expand All @@ -151,6 +159,8 @@ async function load_from_device() {
'scaling': scaling,
'layers': mask_to_layer_list(layer_mask),
'sticky': (mapping_flags & STICKY_FLAG) != 0,
'tap': (mapping_flags & TAP_FLAG) != 0,
'hold': (mapping_flags & HOLD_FLAG) != 0,
});
}

Expand Down Expand Up @@ -202,6 +212,7 @@ async function save_to_device() {
[UINT8, layer_list_to_mask(config['unmapped_passthrough_layers'])],
[UINT32, config['partial_scroll_timeout']],
[UINT8, config['interval_override']],
[UINT32, config['tap_hold_threshold']],
]);
await send_feature_command(CLEAR_MAPPING);

Expand All @@ -211,7 +222,9 @@ async function save_to_device() {
[UINT32, parseInt(mapping['source_usage'], 16)],
[INT32, mapping['scaling']],
[UINT8, layer_list_to_mask(mapping['layers'])],
[UINT8, mapping['sticky'] ? STICKY_FLAG : 0],
[UINT8, (mapping['sticky'] ? STICKY_FLAG : 0)
| (mapping['tap'] ? TAP_FLAG : 0)
| (mapping['hold'] ? HOLD_FLAG : 0)]
]);
}

Expand Down Expand Up @@ -248,8 +261,8 @@ async function save_to_device() {
async function get_usages_from_device() {
try {
await send_feature_command(GET_CONFIG);
const [config_version, flags, partial_scroll_timeout, mapping_count, our_usage_count, their_usage_count] =
await read_config_feature([UINT8, UINT8, UINT32, UINT32, UINT32, UINT32]);
const [config_version, flags, partial_scroll_timeout, mapping_count, our_usage_count, their_usage_count, tap_hold_threshold] =
await read_config_feature([UINT8, UINT8, UINT32, UINT32, UINT32, UINT32, UINT32]);
check_received_version(config_version);

let extra_usage_set = new Set();
Expand Down Expand Up @@ -289,6 +302,7 @@ async function get_usages_from_device() {

function set_config_ui_state() {
document.getElementById('partial_scroll_timeout_input').value = Math.round(config['partial_scroll_timeout'] / 1000);
document.getElementById('tap_hold_threshold_input').value = Math.round(config['tap_hold_threshold'] / 1000);
for (let i = 0; i < NLAYERS; i++) {
document.getElementById('unmapped_passthrough_checkbox' + i).checked =
config['unmapped_passthrough_layers'].includes(i);
Expand Down Expand Up @@ -351,6 +365,13 @@ function set_ui_state() {
delete mapping['layer'];
}
config['macros'] = [[], [], [], [], [], [], [], []];
}
if (config['version'] < 5) {
for (const mapping of config['mappings']) {
mapping['tap'] = false;
mapping['hold'] = false;
}
config['tap_hold_threshold'] = DEFAULT_TAP_HOLD_THRESHOLD;
config['version'] = CONFIG_VERSION;
}

Expand All @@ -367,6 +388,12 @@ function add_mapping(mapping) {
const sticky_checkbox = clone.querySelector(".sticky_checkbox");
sticky_checkbox.checked = mapping['sticky'];
sticky_checkbox.addEventListener("change", sticky_onclick(mapping, sticky_checkbox));
const tap_checkbox = clone.querySelector(".tap_checkbox");
tap_checkbox.checked = mapping['tap'];
tap_checkbox.addEventListener("change", tap_onclick(mapping, tap_checkbox));
const hold_checkbox = clone.querySelector(".hold_checkbox");
hold_checkbox.checked = mapping['hold'];
hold_checkbox.addEventListener("change", hold_onclick(mapping, hold_checkbox));
const scaling_input = clone.querySelector(".scaling_input");
scaling_input.value = mapping['scaling'] / 1000;
scaling_input.addEventListener("input", scaling_onchange(mapping, scaling_input));
Expand Down Expand Up @@ -522,7 +549,7 @@ function add_crc(data) {
}

function check_json_version(config_version) {
if (!([3, 4].includes(config_version))) {
if (!([3, 4, 5].includes(config_version))) {
throw new Error("Incompatible version.");
}
}
Expand All @@ -534,11 +561,11 @@ function check_received_version(config_version) {
}

async function check_device_version() {
// This isn't a reliable way of checking the config version of the device because
// it could be version X, ignore our GET_CONFIG call with version Y and just happen
// to have Y at the right place in the buffer from some previous call done by some
// other software.
for (const version of [CONFIG_VERSION, 3, 2]) {
// For versions <=3, this isn't a reliable way of checking the config version of the
// device because it could be version X, ignore our GET_CONFIG call with version Y and
// just happen to have Y at the right place in the buffer from some previous call done
// by some other software.
for (const version of [CONFIG_VERSION, 4, 3, 2]) {
await send_feature_command(GET_CONFIG, [], version);
const [received_version] = await read_config_feature([UINT8]);
if (received_version == version) {
Expand Down Expand Up @@ -576,6 +603,18 @@ function sticky_onclick(mapping, element) {
};
}

function tap_onclick(mapping, element) {
return function () {
mapping['tap'] = element.checked;
};
}

function hold_onclick(mapping, element) {
return function () {
mapping['hold'] = element.checked;
};
}

function scaling_onchange(mapping, element) {
return function () {
mapping['scaling'] = element.value === '' ? DEFAULT_SCALING : Math.round(parseFloat(element.value) * 1000);
Expand Down Expand Up @@ -631,6 +670,8 @@ function add_mapping_onclick() {
'target_usage': '0x00000000',
'layers': [0],
'sticky': false,
'tap': false,
'hold': false,
'scaling': DEFAULT_SCALING
};
config['mappings'].push(new_mapping);
Expand Down Expand Up @@ -745,6 +786,16 @@ function partial_scroll_timeout_onchange() {
config['partial_scroll_timeout'] = value;
}

function tap_hold_threshold_onchange() {
let value = document.getElementById('tap_hold_threshold_input').value;
if (value === '') {
value = DEFAULT_TAP_HOLD_THRESHOLD;
} else {
value = Math.round(value * 1000);
}
config['tap_hold_threshold'] = value;
}

function unmapped_passthrough_onchange() {
config['unmapped_passthrough_layers'] = [];
for (let i = 0; i < NLAYERS; i++) {
Expand Down Expand Up @@ -844,4 +895,4 @@ function set_forced_layers(mapping, mapping_container) {
}
layer_checkbox.disabled = true;
}
}
}
82 changes: 56 additions & 26 deletions config-tool-web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,18 @@ <h1 class="mt-sm-5 mt-3">HID Remapper Configuration</h1>
<div id="error" class="alert alert-danger d-none"></div>

<div class="row pb-2" style="overflow-x: auto;">
<div style="min-width: 600px; width: 100%;">
<div class="row my-2">
<div class="col-1 text-center"></div>
<div style="min-width: 720px; width: 100%;">
<div class="row my-2 align-items-end">
<div class="col-1"></div>
<div class="col-3 text-center">Input</div>
<div class="col-3 text-center">Output</div>
<div class="col-2 text-center">Layer</div>
<div class="col-3">
<div class="d-flex align-items-end">
<div class="flex-fill text-center">Layer</div>
<div class="text-start pt-1" style="writing-mode: vertical-rl; transform: rotate(180deg); line-height: 1.2em;">Sticky<br>Tap<br>Hold</div>
</div>
</div>
<div class="col-2 text-center">Scaling</div>
<div class="col-1 text-center">Sticky</div>
</div>

<div id="mappings">
Expand Down Expand Up @@ -71,18 +75,29 @@ <h1 class="mt-sm-5 mt-3">HID Remapper Configuration</h1>
</div>
</div>
<div class="row mb-2">
<div class="col-auto">
<div class="col-4 text-end">
<label for="partial_scroll_timeout_input" class="col-form-label">Partial scroll timeout</label>
</div>
<div class="col-auto">
<div class="input-group">
<input type="number" id="partial_scroll_timeout_input" class="form-control" style="max-width: 100px;">
<input type="number" id="partial_scroll_timeout_input" class="form-control text-end" style="max-width: 100px;">
<span class="input-group-text">ms</span>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-4 text-end">
<label for="tap_hold_threshold_input" class="col-form-label">Tap-hold threshold</label>
</div>
<div class="col-auto">
<div class="input-group">
<input type="number" id="tap_hold_threshold_input" class="form-control text-end" style="max-width: 100px;">
<span class="input-group-text">ms</span>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-4 text-end">
<label for="interval_override_dropdown" class="col-form-label">Override polling rate</label>
</div>
<div class="col-auto">
Expand Down Expand Up @@ -140,28 +155,43 @@ <h1 class="mt-sm-5 mt-3">HID Remapper Configuration</h1>
<div class="col-1"><button type="button" class="btn btn-primary delete_button">×</button></div>
<div class="col-3"><button type="button" class="btn btn-primary w-100 source_button">source</button></div>
<div class="col-3"><button type="button" class="btn btn-primary w-100 target_button">target</button></div>
<div class="col-2">
<div class="row justify-content-center gx-1 lh-1 text-center">
<div class="col-auto">
<input class="form-check-input layer_checkbox0 mt-1" type="checkbox" value="">
<br><span class="text-muted small">0</span>
</div>
<div class="col-auto">
<input class="form-check-input layer_checkbox1 mt-1" type="checkbox" value="">
<br><span class="text-muted small">1</span>
</div>
<div class="col-auto">
<input class="form-check-input layer_checkbox2 mt-1" type="checkbox" value="">
<br><span class="text-muted small">2</span>
<div class="col-3">
<div class="d-flex lh-1 text-center">
<div class="flex-fill row gx-1 justify-content-center">
<div class="col-auto">
<input class="form-check-input layer_checkbox0" type="checkbox" value="">
<br><span class="text-muted small">0</span>
</div>
<div class="col-auto">
<input class="form-check-input layer_checkbox1" type="checkbox" value="">
<br><span class="text-muted small">1</span>
</div>
<div class="col-auto">
<input class="form-check-input layer_checkbox2" type="checkbox" value="">
<br><span class="text-muted small">2</span>
</div>
<div class="col-auto">
<input class="form-check-input layer_checkbox3" type="checkbox" value="">
<br><span class="text-muted small">3</span>
</div>
</div>
<div class="col-auto">
<input class="form-check-input layer_checkbox3 mt-1" type="checkbox" value="">
<br><span class="text-muted small">3</span>
<div class="row gx-1">
<div class="col-auto ms-1" title="Sticky">
<input class="form-check-input sticky_checkbox" type="checkbox" value="">
<br><span class="text-muted small">S</span>
</div>
<div class="col-auto" title="Tap">
<input class="form-check-input tap_checkbox" type="checkbox" value="">
<br><span class="text-muted small">T</span>
</div>
<div class="col-auto" title="Hold">
<input class="form-check-input hold_checkbox" type="checkbox" value="">
<br><span class="text-muted small">H</span>
</div>
</div>
</div>
</div>
<div class="col-2"><input class="form-control scaling_input" type="number"></div>
<div class="col-1 d-flex justify-content-center"><input class="form-check-input sticky_checkbox mt-2" type="checkbox" value=""></div>
<div class="col-2"><input class="form-control scaling_input text-end" type="number"></div>
</div>
</template>

Expand Down Expand Up @@ -236,4 +266,4 @@ <h5 class="modal-title usage_modal_title"></h5>

</body>

</html>
</html>
10 changes: 8 additions & 2 deletions config-tool/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@

CONFIG_USAGE_PAGE = 0xFF00

CONFIG_VERSION = 4
CONFIG_VERSION = 5
CONFIG_SIZE = 32
REPORT_ID_CONFIG = 100

DEFAULT_PARTIAL_SCROLL_TIMEOUT = 1000000
DEFAULT_TAP_HOLD_THRESHOLD = 200000
DEFAULT_SCALING = 1000

NLAYERS = 4

RESET_INTO_BOOTSEL = 1
Expand All @@ -33,7 +37,9 @@
GET_MACRO = 17

UNMAPPED_PASSTHROUGH_FLAG = 0x01
STICKY_FLAG = 0x01
STICKY_FLAG = 1 << 0
TAP_FLAG = 1 << 1
HOLD_FLAG = 1 << 2

NMACROS = 8
MACRO_ITEMS_IN_PACKET = 6
Expand Down
Loading

0 comments on commit 2c067c1

Please sign in to comment.