Macro keys (#878)
Some checks are pending
Check layouts / check_layout.output (push) Waiting to run
Check layouts / Generated files (push) Waiting to run
Check translations / check-translations (push) Waiting to run
Make Apk CI / Build-Apk (push) Waiting to run

Add "macro" keys that behave as if a sequence of keys is typed.

Macro can be added to custom layouts or through the "Add keys to the
keyboard option". The syntax is:

    symbol:key1,key2,..

The symbol cannot contain a : character. 'key1', 'key2', etc.. are:

  - 'String with \' escaping'
    The key will generate the specified string.
  - keyevent:123
    The key will send a keyevent.
  - The name of any special key
This commit is contained in:
Jules Aguillon 2025-02-23 12:12:29 +01:00 committed by GitHub
parent 581b31bf99
commit 68be82a4f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 437 additions and 165 deletions

View File

@ -1,9 +1,30 @@
# Key values # Key values
This is an exhaustive list of special values accepted for the `key0` through `key8` or `nw` through `se` attributes on a key. A key value is the denomination of a key accepted in the "Add keys to the keyboard" option or for the `nw`, ..., `se` attributes in custom layouts (or `key0` ... `key8`).
It can be:
Any string that does not exactly match these will be printed verbatim. - The name of a special key. An exhaustive list of the special keys follows.
A key can output multiple characters, but cannot combine multiple built-in key values.
- An arbitrary sequence of characters not containing `:`.
This results in a key that writes the specified characters.
- Using the syntax `symbol:key_def`.
`symbol` is the symbol that appears on the keyboard, it cannot contain `:`.
`key_def` can be:
+ The name of a special key, as listed below.
+ `'Arbitrary string'` An arbitrary string that can contain `:`. `'` can be added to the string as `` \' ``.
+ `keyevent:keycode` An Android keycode. They are listed as `KEYCODE_...` in [KeyEvent](https://developer.android.com/reference/android/view/KeyEvent#summary).
Examples:
+ `⏯:keyevent:85` A play/pause key (which probably doesn't do anything in most apps).
+ `my@:'my.email@domain.com'` An arbitrary string key
- A macro, `symbol:key_def1,key_def2,...`.
This results in a key that behaves as if the sequence of `key_def` had been pressed in order.
Examples:
+ `CA:ctrl,a,ctrl,c` The sequence `ctrl+a`, `ctrl+c`.
+ `Cd:ctrl,backspace` The shortcut `ctrl+backspace`.
## Escape codes ## Escape codes
Value | Escape code for Value | Escape code for
@ -152,50 +173,3 @@ These keys are known to do nothing.
These keys are normally hidden unless the Fn modifier is activated. These keys are normally hidden unless the Fn modifier is activated.
`f11_placeholder` | `f12_placeholder` `f11_placeholder` | `f12_placeholder`
## Complex keys
More complex keys are of this form:
```
:<kind> <attributes>:<payload>
```
Where `<kind>` is one of the kinds documented below and `<attributes>` is a
space separated list of attributes. `<payload>` depends on the `<kind>`.
Attributes are:
- `symbol='Sym'` specifies the symbol to be shown on the keyboard.
- `flags='<flags>'` changes the behavior of the key.
`<flags>` is a coma separated list of:
+ `dim`: Make the symbol dimmer.
+ `small`: Make the symbol smaller.
### Kind `str`
Defines a key that outputs an arbitrary string. `<payload>` is a string wrapped
in single-quotes (`'`), escaping of other single quotes is allowed with `\'`.
For example:
- `:str:'Arbitrary string with a \' inside'`
- `:str symbol='Symbol':'Output string'`
### Kind `char`
Defines a key that outputs a single character. `<payload>` is the character to
output, unquoted.
This kind of key can be used to define a character key with a different symbol
on it. `char` keys can be modified by `ctrl` and other modifiers, unlike `str`
keys.
For example:
- `:char symbol='љ':q`, which is used to implement `ctrl` shortcuts in cyrillic
layouts.
### Kind `keyevent`
Defines a key that sends an Android [key event](https://developer.android.com/reference/android/view/KeyEvent).
`<payload>` is the key event number.
For example:
- `:keyevent symbol='⏯' flags='small':85`

View File

@ -88,6 +88,24 @@ public final class Autocapitalisation
callback_now(true); callback_now(true);
} }
/** Pause auto capitalisation until [unpause()] is called. */
public boolean pause()
{
boolean was_enabled = _enabled;
stop();
_enabled = false;
return was_enabled;
}
/** Continue auto capitalisation after [pause()] was called. Argument is the
output of [pause()]. */
public void unpause(boolean was_enabled)
{
_enabled = was_enabled;
_should_update_caps_mode = true;
callback_now(true);
}
public static interface Callback public static interface Callback
{ {
public void update_shift_state(boolean should_enable, boolean should_disable); public void update_shift_state(boolean should_enable, boolean should_disable);

View File

@ -97,11 +97,10 @@ public final class KeyEventHandler
case Keyevent: send_key_down_up(key.getKeyevent()); break; case Keyevent: send_key_down_up(key.getKeyevent()); break;
case Modifier: break; case Modifier: break;
case Editing: handle_editing_key(key.getEditing()); break; case Editing: handle_editing_key(key.getEditing()); break;
case Compose_pending: case Compose_pending: _recv.set_compose_pending(true); break;
_recv.set_compose_pending(true);
break;
case Slider: handle_slider(key.getSlider(), key.getSliderRepeat()); break; case Slider: handle_slider(key.getSlider(), key.getSliderRepeat()); break;
case StringWithSymbol: send_text(key.getStringWithSymbol()); break; case StringWithSymbol: send_text(key.getStringWithSymbol()); break;
case Macro: evaluate_macro(key.getMacro()); break;
} }
update_meta_state(old_mods); update_meta_state(old_mods);
} }
@ -319,6 +318,35 @@ public final class KeyEventHandler
send_key_down_up_repeat(KeyEvent.KEYCODE_DPAD_DOWN, d); send_key_down_up_repeat(KeyEvent.KEYCODE_DPAD_DOWN, d);
} }
void evaluate_macro(KeyValue[] keys)
{
final Pointers.Modifiers empty = Pointers.Modifiers.EMPTY;
// Ignore modifiers that are activated at the time the macro is evaluated
mods_changed(empty);
Pointers.Modifiers mods = empty;
final boolean autocap_paused = _autocap.pause();
for (KeyValue kv : keys)
{
kv = KeyModifier.modify(kv, mods);
if (kv == null)
continue;
if (kv.hasFlagsAny(KeyValue.FLAG_LATCH))
{
// Non-special latchable keys clear latched modifiers
if (!kv.hasFlagsAny(KeyValue.FLAG_SPECIAL))
mods = empty;
mods = mods.with_extra_mod(kv);
}
else
{
key_down(kv, false);
key_up(kv, mods);
mods = empty;
}
}
_autocap.unpause(autocap_paused);
}
/** Repeat calls to [send_key_down_up]. */ /** Repeat calls to [send_key_down_up]. */
void send_key_down_up_repeat(int event_code, int repeat) void send_key_down_up_repeat(int event_code, int repeat)
{ {

View File

@ -96,6 +96,7 @@ public final class KeyValue implements Comparable<KeyValue>
String, // [_payload] is also the string to output, value is unused. String, // [_payload] is also the string to output, value is unused.
Slider, // [_payload] is a [KeyValue.Slider], value is slider repeatition. Slider, // [_payload] is a [KeyValue.Slider], value is slider repeatition.
StringWithSymbol, // [_payload] is a [KeyValue.StringWithSymbol], value is unused. StringWithSymbol, // [_payload] is a [KeyValue.StringWithSymbol], value is unused.
Macro, // [_payload] is a [KeyValue.Macro], value is unused.
} }
private static final int FLAGS_OFFSET = 20; private static final int FLAGS_OFFSET = 20;
@ -105,7 +106,8 @@ public final class KeyValue implements Comparable<KeyValue>
public static final int FLAG_LATCH = (1 << FLAGS_OFFSET << 0); public static final int FLAG_LATCH = (1 << FLAGS_OFFSET << 0);
// Key can be locked by typing twice when enabled in settings // Key can be locked by typing twice when enabled in settings
public static final int FLAG_DOUBLE_TAP_LOCK = (1 << FLAGS_OFFSET << 1); public static final int FLAG_DOUBLE_TAP_LOCK = (1 << FLAGS_OFFSET << 1);
// Special keys are not repeated and don't clear latched modifiers. // Special keys are not repeated.
// Special latchable keys don't clear latched modifiers.
public static final int FLAG_SPECIAL = (1 << FLAGS_OFFSET << 2); public static final int FLAG_SPECIAL = (1 << FLAGS_OFFSET << 2);
// Whether the symbol should be greyed out. For example, keys that are not // Whether the symbol should be greyed out. For example, keys that are not
// part of the pending compose sequence. // part of the pending compose sequence.
@ -229,6 +231,12 @@ public final class KeyValue implements Comparable<KeyValue>
return ((StringWithSymbol)_payload).str; return ((StringWithSymbol)_payload).str;
} }
/** Defined only when [getKind() == Kind.Macro]. */
public KeyValue[] getMacro()
{
return ((Macro)_payload).keys;
}
/* Update the char and the symbol. */ /* Update the char and the symbol. */
public KeyValue withChar(char c) public KeyValue withChar(char c)
{ {
@ -460,31 +468,35 @@ public final class KeyValue implements Comparable<KeyValue>
Kind.StringWithSymbol, 0, flags); Kind.StringWithSymbol, 0, flags);
} }
public static KeyValue makeMacro(String symbol, KeyValue[] keys, int flags)
{
return new KeyValue(new Macro(keys, symbol), Kind.Macro, 0, flags);
}
/** Make a modifier key for passing to [KeyModifier]. */ /** Make a modifier key for passing to [KeyModifier]. */
public static KeyValue makeInternalModifier(Modifier mod) public static KeyValue makeInternalModifier(Modifier mod)
{ {
return new KeyValue("", Kind.Modifier, mod.ordinal(), 0); return new KeyValue("", Kind.Modifier, mod.ordinal(), 0);
} }
public static KeyValue parseKeyDefinition(String str) /** Return a key by its name. If the given name doesn't correspond to any
special key, it is parsed with [KeyValueParser]. */
public static KeyValue getKeyByName(String name)
{ {
if (str.length() < 2 || str.charAt(0) != ':') KeyValue k = getSpecialKeyByName(name);
return makeStringKey(str); if (k != null)
return k;
try try
{ {
return KeyValueParser.parse(str); return KeyValueParser.parse(name);
} }
catch (KeyValueParser.ParseError _e) catch (KeyValueParser.ParseError _e)
{ {
return makeStringKey(str); return makeStringKey(name);
} }
} }
/** public static KeyValue getSpecialKeyByName(String name)
* Return a key by its name. If the given name doesn't correspond to a key
* defined in this function, it is passed to [parseStringKey] as a fallback.
*/
public static KeyValue getKeyByName(String name)
{ {
switch (name) switch (name)
{ {
@ -735,8 +747,7 @@ public final class KeyValue implements Comparable<KeyValue>
case "": case "": case "": case "":
return makeStringKey(name, FLAG_SMALLER_FONT); return makeStringKey(name, FLAG_SMALLER_FONT);
/* The key is not one of the special ones. */ default: return null;
default: return parseKeyDefinition(name);
} }
} }
@ -787,4 +798,31 @@ public final class KeyValue implements Comparable<KeyValue>
@Override @Override
public String toString() { return symbol; } public String toString() { return symbol; }
}; };
public static final class Macro implements Comparable<Macro>
{
public final KeyValue[] keys;
private final String _symbol;
public Macro(KeyValue[] keys_, String sym_)
{
keys = keys_;
_symbol = sym_;
}
public String toString() { return _symbol; }
@Override
public int compareTo(Macro snd)
{
int d = keys.length - snd.keys.length;
if (d != 0) return d;
for (int i = 0; i < keys.length; i++)
{
d = keys[i].compareTo(snd.keys[i]);
if (d != 0) return d;
}
return _symbol.compareTo(snd._symbol);
}
};
} }

View File

@ -1,14 +1,22 @@
package juloo.keyboard2; package juloo.keyboard2;
import java.util.ArrayList;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
Parse a key definition. The syntax for a key definition is: Parse a key definition. The syntax for a key definition is:
- [(symbol):(key_action)]
- [:(kind) (attributes):(payload)]. - [:(kind) (attributes):(payload)].
- If [str] doesn't start with a [:] character, it is interpreted as an - If [str] doesn't start with a [:] character, it is interpreted as an
arbitrary string key. arbitrary string key.
[key_action] is:
- ['Arbitrary string']
- [(key_action),(key_action),...]
- [keyevent:(code)]
- [(key_name)]
For the different kinds and attributes, see doc/Possible-key-values.md. For the different kinds and attributes, see doc/Possible-key-values.md.
Examples: Examples:
@ -18,6 +26,116 @@ Examples:
*/ */
public final class KeyValueParser public final class KeyValueParser
{ {
static Pattern KEYDEF_TOKEN;
static Pattern QUOTED_PAT;
static Pattern WORD_PAT;
static public KeyValue parse(String input) throws ParseError
{
int symbol_ends = 0;
final int input_len = input.length();
while (symbol_ends < input_len && input.charAt(symbol_ends) != ':')
symbol_ends++;
if (symbol_ends == 0) // Old syntax
return Starting_with_colon.parse(input);
if (symbol_ends == input_len) // String key
return KeyValue.makeStringKey(input);
String symbol = input.substring(0, symbol_ends);
ArrayList<KeyValue> keydefs = new ArrayList<KeyValue>();
init();
Matcher m = KEYDEF_TOKEN.matcher(input);
m.region(symbol_ends + 1, input_len);
do { keydefs.add(parse_key_def(m)); }
while (parse_comma(m));
for (KeyValue k : keydefs)
if (k == null)
parseError("Contains null key", m);
return KeyValue.makeMacro(symbol, keydefs.toArray(new KeyValue[]{}), 0);
}
static void init()
{
if (KEYDEF_TOKEN != null)
return;
KEYDEF_TOKEN = Pattern.compile("'|,|keyevent:|(?:[^\\\\',]+|\\\\.)+");
QUOTED_PAT = Pattern.compile("((?:[^'\\\\]+|\\\\')*)'");
WORD_PAT = Pattern.compile("[a-zA-Z0-9_]+|.");
}
static KeyValue key_by_name_or_str(String str)
{
KeyValue k = KeyValue.getSpecialKeyByName(str);
if (k != null)
return k;
return KeyValue.makeStringKey(str);
}
static KeyValue parse_key_def(Matcher m) throws ParseError
{
if (!match(m, KEYDEF_TOKEN))
parseError("Expected key definition", m);
String token = m.group(0);
switch (token)
{
case "'": return parse_string_keydef(m);
case ",": parseError("Unexpected comma", m); return null;
case "keyevent:": return parse_keyevent_keydef(m);
default: return key_by_name_or_str(remove_escaping(token));
}
}
static KeyValue parse_string_keydef(Matcher m) throws ParseError
{
if (!match(m, QUOTED_PAT))
parseError("Unterminated quoted string", m);
return KeyValue.makeStringKey(remove_escaping(m.group(1)));
}
static KeyValue parse_keyevent_keydef(Matcher m) throws ParseError
{
if (!match(m, WORD_PAT))
parseError("Expected keyevent code", m);
int eventcode = 0;
try { eventcode = Integer.parseInt(m.group(0)); }
catch (Exception _e)
{ parseError("Expected an integer payload", m); }
return KeyValue.keyeventKey("", eventcode, 0);
}
/** Returns [true] if the next token is a comma, [false] if it is the end of the input. Throws an error otherwise. */
static boolean parse_comma(Matcher m) throws ParseError
{
if (!match(m, KEYDEF_TOKEN))
return false;
String token = m.group(0);
if (!token.equals(","))
parseError("Expected comma instead of '"+ token + "'", m);
return true;
}
static String remove_escaping(String s)
{
if (!s.contains("\\"))
return s;
StringBuilder out = new StringBuilder(s.length());
final int len = s.length();
int prev = 0, i = 0;
for (; i < len; i++)
if (s.charAt(i) == '\\')
{
out.append(s, prev, i);
prev = i + 1;
}
out.append(s, prev, i);
return out.toString();
}
/**
Parse a key definition starting with a [:]. This is the old syntax and is
kept for compatibility.
*/
final static class Starting_with_colon
{
static Pattern START_PAT; static Pattern START_PAT;
static Pattern ATTR_PAT; static Pattern ATTR_PAT;
static Pattern QUOTED_PAT; static Pattern QUOTED_PAT;
@ -134,6 +252,14 @@ public final class KeyValueParser
PAYLOAD_START_PAT = Pattern.compile("\\s*:"); PAYLOAD_START_PAT = Pattern.compile("\\s*:");
WORD_PAT = Pattern.compile("[a-zA-Z0-9_]*"); WORD_PAT = Pattern.compile("[a-zA-Z0-9_]*");
} }
}
static boolean match(Matcher m, Pattern pat)
{
try { m.region(m.end(), m.regionEnd()); } catch (Exception _e) {}
m.usePattern(pat);
return m.lookingAt();
}
static void parseError(String msg, Matcher m) throws ParseError static void parseError(String msg, Matcher m) throws ParseError
{ {
@ -145,8 +271,7 @@ public final class KeyValueParser
StringBuilder msg_ = new StringBuilder("Syntax error"); StringBuilder msg_ = new StringBuilder("Syntax error");
try try
{ {
char c = m.group(0).charAt(0); msg_.append(" at token '").append(m.group(0)).append("'");
msg_.append(" at character '").append(c).append("'");
} catch (IllegalStateException _e) {} } catch (IllegalStateException _e) {}
msg_.append(" at position "); msg_.append(" at position ");
msg_.append(i); msg_.append(i);

View File

@ -41,7 +41,7 @@ public class CustomExtraKeysPreference extends ListGroupPreference<String>
if (key_names != null) if (key_names != null)
{ {
for (String key_name : key_names) for (String key_name : key_names)
kvs.put(KeyValue.parseKeyDefinition(key_name), KeyboardData.PreferredPos.DEFAULT); kvs.put(KeyValue.getKeyByName(key_name), KeyboardData.PreferredPos.DEFAULT);
} }
return kvs; return kvs;
} }

View File

@ -10,7 +10,100 @@ public class KeyValueParserTest
public KeyValueParserTest() {} public KeyValueParserTest() {}
@Test @Test
public void parseStr() throws Exception public void parse_key_value() throws Exception
{
Utils.parse("'", KeyValue.makeStringKey("'"));
Utils.parse("\\'", KeyValue.makeStringKey("\\'"));
Utils.parse("\\,", KeyValue.makeStringKey("\\,"));
Utils.parse("a\\'b", KeyValue.makeStringKey("a\\'b"));
Utils.parse("a\\,b", KeyValue.makeStringKey("a\\,b"));
Utils.parse("a", KeyValue.makeStringKey("a"));
Utils.parse("abc", KeyValue.makeStringKey("abc"));
Utils.parse("shift", KeyValue.getSpecialKeyByName("shift"));
Utils.parse("'a", KeyValue.makeStringKey("'a"));
}
@Test
public void parse_macro() throws Exception
{
Utils.parse("symbol:abc", KeyValue.makeMacro("symbol", new KeyValue[]{
KeyValue.makeStringKey("abc")
}, 0));
Utils.parse("copy:ctrl,a,ctrl,c", KeyValue.makeMacro("copy", new KeyValue[]{
KeyValue.getSpecialKeyByName("ctrl"),
KeyValue.makeStringKey("a"),
KeyValue.getSpecialKeyByName("ctrl"),
KeyValue.makeStringKey("c")
}, 0));
Utils.parse("macro:abc,\\'", KeyValue.makeMacro("macro", new KeyValue[]{
KeyValue.makeStringKey("abc"),
KeyValue.makeStringKey("'")
}, 0));
Utils.parse("macro:abc,\\,", KeyValue.makeMacro("macro", new KeyValue[]{
KeyValue.makeStringKey("abc"),
KeyValue.makeStringKey(",")
}, 0));
Utils.parse("<2:ctrl,backspace", KeyValue.makeMacro("<2", new KeyValue[]{
KeyValue.getSpecialKeyByName("ctrl"),
KeyValue.getSpecialKeyByName("backspace")
}, 0));
Utils.expect_error("symbol:");
Utils.expect_error("unterminated_string:'");
Utils.expect_error("unterminated_string:abc,'");
Utils.expect_error("unexpected_quote:abc,,");
Utils.expect_error("unexpected_quote:,");
}
@Test
public void parse_string_key() throws Exception
{
Utils.parse("symbol:'str'", KeyValue.makeMacro("symbol", new KeyValue[]{
KeyValue.makeStringKey("str")
}, 0));
Utils.parse("symbol:'str\\''", KeyValue.makeMacro("symbol", new KeyValue[]{
KeyValue.makeStringKey("str'")
}, 0));
Utils.parse("macro:'str',abc", KeyValue.makeMacro("macro", new KeyValue[]{
KeyValue.makeStringKey("str"),
KeyValue.makeStringKey("abc")
}, 0));
Utils.parse("macro:abc,'str'", KeyValue.makeMacro("macro", new KeyValue[]{
KeyValue.makeStringKey("abc"),
KeyValue.makeStringKey("str")
}, 0));
Utils.parse("macro:\\',\\,", KeyValue.makeMacro("macro", new KeyValue[]{
KeyValue.makeStringKey("'"),
KeyValue.makeStringKey(","),
}, 0));
Utils.parse("macro:a\\'b,a\\,b,a\\xb", KeyValue.makeMacro("macro", new KeyValue[]{
KeyValue.makeStringKey("a'b"),
KeyValue.makeStringKey("a,b"),
KeyValue.makeStringKey("axb")
}, 0));
Utils.expect_error("symbol:'");
Utils.expect_error("symbol:'foo");
}
@Test
public void parse_key_event() throws Exception
{
Utils.parse("symbol:keyevent:85", KeyValue.makeMacro("symbol", new KeyValue[]{
KeyValue.keyeventKey("", 85, 0)
}, 0));
Utils.parse("macro:keyevent:85,abc", KeyValue.makeMacro("macro", new KeyValue[]{
KeyValue.keyeventKey("", 85, 0),
KeyValue.makeStringKey("abc")
}, 0));
Utils.parse("macro:abc,keyevent:85", KeyValue.makeMacro("macro", new KeyValue[]{
KeyValue.makeStringKey("abc"),
KeyValue.keyeventKey("", 85, 0)
}, 0));
Utils.expect_error("symbol:keyevent:");
Utils.expect_error("symbol:keyevent:85a");
}
@Test
public void parse_old_syntax() throws Exception
{ {
Utils.parse(":str:'Foo'", KeyValue.makeStringKey("Foo")); Utils.parse(":str:'Foo'", KeyValue.makeStringKey("Foo"));
Utils.parse(":str flags='dim':'Foo'", KeyValue.makeStringKey("Foo", KeyValue.FLAG_SECONDARY)); Utils.parse(":str flags='dim':'Foo'", KeyValue.makeStringKey("Foo", KeyValue.FLAG_SECONDARY));
@ -32,11 +125,7 @@ public class KeyValueParserTest
Utils.expect_error(":str flags='' "); Utils.expect_error(":str flags='' ");
Utils.expect_error(":str flags='':"); Utils.expect_error(":str flags='':");
Utils.expect_error(":str flags='':'"); Utils.expect_error(":str flags='':'");
} // Char
@Test
public void parseChar() throws Exception
{
Utils.parse(":char symbol='a':b", KeyValue.makeCharKey('b', "a", 0)); Utils.parse(":char symbol='a':b", KeyValue.makeCharKey('b', "a", 0));
Utils.parse(":char:b", KeyValue.makeCharKey('b', "b", 0)); Utils.parse(":char:b", KeyValue.makeCharKey('b', "b", 0));
} }
@ -46,7 +135,7 @@ public class KeyValueParserTest
{ {
static void parse(String key_descr, KeyValue ref) throws Exception static void parse(String key_descr, KeyValue ref) throws Exception
{ {
assertEquals(ref, KeyValueParser.parse(key_descr)); assertEquals(ref, KeyValue.getKeyByName(key_descr));
} }
static void expect_error(String key_descr) static void expect_error(String key_descr)