New syntax for macros

Macros can now be written in a more elegant syntax:

    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-22 12:19:51 +01:00
parent f3a0c89da1
commit 8d90e3c4d2
4 changed files with 341 additions and 169 deletions

View File

@ -479,30 +479,21 @@ public final class KeyValue implements Comparable<KeyValue>
return new KeyValue("", Kind.Modifier, mod.ordinal(), 0);
}
public static KeyValue parseKeyDefinition(String str)
{
if (str.length() < 2 || str.charAt(0) != ':')
return makeStringKey(str);
try
{
return KeyValueParser.parse(str);
}
catch (KeyValueParser.ParseError _e)
{
return makeStringKey(str);
}
}
/**
* 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.
*/
/** 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)
{
KeyValue k = getSpecialKeyByName(name);
if (k == null)
return parseKeyDefinition(name);
return k;
if (k != null)
return k;
try
{
return KeyValueParser.parse(name);
}
catch (KeyValueParser.ParseError _e)
{
return makeStringKey(name);
}
}
public static KeyValue getSpecialKeyByName(String name)
@ -821,6 +812,7 @@ public final class KeyValue implements Comparable<KeyValue>
public String toString() { return _symbol; }
@Override
public int compareTo(Macro snd)
{
int d = keys.length - snd.keys.length;

View File

@ -6,10 +6,17 @@ import java.util.regex.Pattern;
/**
Parse a key definition. The syntax for a key definition is:
- [(symbol):(key_action)]
- [:(kind) (attributes):(payload)].
- If [str] doesn't start with a [:] character, it is interpreted as an
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.
Examples:
@ -19,154 +26,265 @@ Examples:
*/
public final class KeyValueParser
{
static Pattern START_PAT;
static Pattern ATTR_PAT;
static Pattern KEYDEF_TOKEN;
static Pattern QUOTED_PAT;
static Pattern PAYLOAD_START_PAT;
static Pattern WORD_PAT;
static Pattern COMMA_PAT;
static Pattern END_OF_INPUT_PAT;
static public KeyValue parse(String str) throws ParseError
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 = START_PAT.matcher(str);
KeyValue k = parseKeyValue(m);
if (!match(m, END_OF_INPUT_PAT))
parseError("Unexpected character", m);
return k;
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 (START_PAT != null)
if (KEYDEF_TOKEN != null)
return;
START_PAT = Pattern.compile(":(\\w+)");
ATTR_PAT = Pattern.compile("\\s*(\\w+)\\s*=");
QUOTED_PAT = Pattern.compile("'(([^'\\\\]+|\\\\')*)'");
PAYLOAD_START_PAT = Pattern.compile("\\s*:");
KEYDEF_TOKEN = Pattern.compile("'|,|keyevent:|(?:[^\\\\',]+|\\\\.)+");
QUOTED_PAT = Pattern.compile("((?:[^'\\\\]+|\\\\')*)'");
WORD_PAT = Pattern.compile("[a-zA-Z0-9_]+|.");
COMMA_PAT = Pattern.compile(",");
END_OF_INPUT_PAT = Pattern.compile("$");
}
static KeyValue parseKeyValue(Matcher m) throws ParseError
static KeyValue key_by_name_or_str(String str)
{
if (match(m, START_PAT))
return parseComplexKeyValue(m, m.group(1));
// Key doesn't start with ':', accept either a char key or a key name.
if (!match(m, WORD_PAT))
parseError("Expected key, for example \":str ...\".", m);
String key = m.group(0);
KeyValue k = KeyValue.getSpecialKeyByName(key);
if (k == null)
return KeyValue.makeStringKey(key);
return k;
KeyValue k = KeyValue.getSpecialKeyByName(str);
if (k != null)
return k;
return KeyValue.makeStringKey(str);
}
static KeyValue parseComplexKeyValue(Matcher m, String kind) throws ParseError
static KeyValue parse_key_def(Matcher m) throws ParseError
{
// Attributes
String symbol = null;
int flags = 0;
while (true)
if (!match(m, KEYDEF_TOKEN))
parseError("Expected key definition", m);
String token = m.group(0);
switch (token)
{
if (!match(m, ATTR_PAT))
break;
String attr_name = m.group(1);
String attr_value = parseSingleQuotedString(m);
switch (attr_name)
{
case "flags":
flags = parseFlags(attr_value, m);
break;
case "symbol":
symbol = attr_value;
break;
default:
parseError("Unknown attribute "+attr_name, m);
}
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));
}
// Payload
if (!match(m, PAYLOAD_START_PAT))
parseError("Unexpected character", m);
String payload;
switch (kind)
{
case "str":
payload = parseSingleQuotedString(m);
if (symbol == null)
return KeyValue.makeStringKey(payload, flags);
return KeyValue.makeStringKeyWithSymbol(payload, symbol, flags);
case "char":
payload = parsePayloadWord(m);
if (payload.length() != 1)
parseError("Expected a single character payload", m);
return KeyValue.makeCharKey(payload.charAt(0), symbol, flags);
case "keyevent":
payload = parsePayloadWord(m);
int eventcode = 0;
try { eventcode = Integer.parseInt(payload); }
catch (Exception _e)
{ parseError("Expected an integer payload", m); }
if (symbol == null)
symbol = String.valueOf(eventcode);
return KeyValue.keyeventKey(symbol, eventcode, flags);
case "macro":
// :macro symbol='copy':ctrl,a,ctrl,c
// :macro symbol='acute':compose,'
KeyValue[] macro = parseKeyValueList(m);
if (symbol == null)
symbol = "macro";
return KeyValue.makeMacro(symbol, macro, flags);
default: break;
}
parseError("Unknown kind '"+kind+"'", m, 1);
return null; // Unreachable
}
static String parseSingleQuotedString(Matcher m) throws ParseError
static KeyValue parse_string_keydef(Matcher m) throws ParseError
{
if (!match(m, QUOTED_PAT))
parseError("Expected quoted string", m);
return m.group(1).replace("\\'", "'");
parseError("Unterminated quoted string", m);
return KeyValue.makeStringKey(remove_escaping(m.group(1)));
}
static String parsePayloadWord(Matcher m) throws ParseError
static KeyValue parse_keyevent_keydef(Matcher m) throws ParseError
{
if (!match(m, WORD_PAT))
parseError("Expected a word after ':' made of [a-zA-Z0-9_]", m);
return m.group(0);
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);
}
static int parseFlags(String s, Matcher m) throws ParseError
/** 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
{
int flags = 0;
for (String f : s.split(","))
{
switch (f)
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) == '\\')
{
case "dim": flags |= KeyValue.FLAG_SECONDARY; break;
case "small": flags |= KeyValue.FLAG_SMALLER_FONT; break;
default: parseError("Unknown flag "+f, m);
out.append(s, prev, i);
prev = i + 1;
}
}
return flags;
out.append(s, prev, i);
return out.toString();
}
// Parse keys separated by comas
static KeyValue[] parseKeyValueList(Matcher m) throws ParseError
/**
Parse a key definition starting with a [:]. This is the old syntax and is
kept for compatibility.
*/
final static class Starting_with_colon
{
ArrayList<KeyValue> out = new ArrayList<KeyValue>();
out.add(parseKeyValue(m));
while (match(m, COMMA_PAT))
static Pattern START_PAT;
static Pattern ATTR_PAT;
static Pattern QUOTED_PAT;
static Pattern PAYLOAD_START_PAT;
static Pattern WORD_PAT;
static Pattern COMMA_PAT;
static Pattern END_OF_INPUT_PAT;
static public KeyValue parse(String str) throws ParseError
{
init();
Matcher m = START_PAT.matcher(str);
KeyValue k = parseKeyValue(m);
if (!match(m, END_OF_INPUT_PAT))
parseError("Unexpected character", m);
return k;
}
static void init()
{
if (START_PAT != null)
return;
START_PAT = Pattern.compile(":(\\w+)");
ATTR_PAT = Pattern.compile("\\s*(\\w+)\\s*=");
QUOTED_PAT = Pattern.compile("'(([^'\\\\]+|\\\\')*)'");
PAYLOAD_START_PAT = Pattern.compile("\\s*:");
WORD_PAT = Pattern.compile("[a-zA-Z0-9_]+|.");
COMMA_PAT = Pattern.compile(",");
END_OF_INPUT_PAT = Pattern.compile("$");
}
static KeyValue parseKeyValue(Matcher m) throws ParseError
{
if (match(m, START_PAT))
return parseComplexKeyValue(m, m.group(1));
// Key doesn't start with ':', accept either a char key or a key name.
if (!match(m, WORD_PAT))
parseError("Expected key, for example \":str ...\".", m);
String key = m.group(0);
KeyValue k = KeyValue.getSpecialKeyByName(key);
if (k == null)
return KeyValue.makeStringKey(key);
return k;
}
static KeyValue parseComplexKeyValue(Matcher m, String kind) throws ParseError
{
// Attributes
String symbol = null;
int flags = 0;
while (true)
{
if (!match(m, ATTR_PAT))
break;
String attr_name = m.group(1);
String attr_value = parseSingleQuotedString(m);
switch (attr_name)
{
case "flags":
flags = parseFlags(attr_value, m);
break;
case "symbol":
symbol = attr_value;
break;
default:
parseError("Unknown attribute "+attr_name, m);
}
}
// Payload
if (!match(m, PAYLOAD_START_PAT))
parseError("Unexpected character", m);
String payload;
switch (kind)
{
case "str":
payload = parseSingleQuotedString(m);
if (symbol == null)
return KeyValue.makeStringKey(payload, flags);
return KeyValue.makeStringKeyWithSymbol(payload, symbol, flags);
case "char":
payload = parsePayloadWord(m);
if (payload.length() != 1)
parseError("Expected a single character payload", m);
return KeyValue.makeCharKey(payload.charAt(0), symbol, flags);
case "keyevent":
payload = parsePayloadWord(m);
int eventcode = 0;
try { eventcode = Integer.parseInt(payload); }
catch (Exception _e)
{ parseError("Expected an integer payload", m); }
if (symbol == null)
symbol = String.valueOf(eventcode);
return KeyValue.keyeventKey(symbol, eventcode, flags);
case "macro":
// :macro symbol='copy':ctrl,a,ctrl,c
// :macro symbol='acute':compose,'
KeyValue[] macro = parseKeyValueList(m);
if (symbol == null)
symbol = "macro";
return KeyValue.makeMacro(symbol, macro, flags);
default: break;
}
parseError("Unknown kind '"+kind+"'", m, 1);
return null; // Unreachable
}
static String parseSingleQuotedString(Matcher m) throws ParseError
{
if (!match(m, QUOTED_PAT))
parseError("Expected quoted string", m);
return m.group(1).replace("\\'", "'");
}
static String parsePayloadWord(Matcher m) throws ParseError
{
if (!match(m, WORD_PAT))
parseError("Expected a word after ':' made of [a-zA-Z0-9_]", m);
return m.group(0);
}
static int parseFlags(String s, Matcher m) throws ParseError
{
int flags = 0;
for (String f : s.split(","))
{
switch (f)
{
case "dim": flags |= KeyValue.FLAG_SECONDARY; break;
case "small": flags |= KeyValue.FLAG_SMALLER_FONT; break;
default: parseError("Unknown flag "+f, m);
}
}
return flags;
}
// Parse keys separated by comas
static KeyValue[] parseKeyValueList(Matcher m) throws ParseError
{
ArrayList<KeyValue> out = new ArrayList<KeyValue>();
out.add(parseKeyValue(m));
return out.toArray(new KeyValue[]{});
while (match(m, COMMA_PAT))
out.add(parseKeyValue(m));
return out.toArray(new KeyValue[]{});
}
}
static boolean match(Matcher m, Pattern pat)
@ -186,8 +304,7 @@ public final class KeyValueParser
StringBuilder msg_ = new StringBuilder("Syntax error");
try
{
char c = m.group(0).charAt(0);
msg_.append(" at character '").append(c).append("'");
msg_.append(" at token '").append(m.group(0)).append("'");
} catch (IllegalStateException _e) {}
msg_.append(" at position ");
msg_.append(i);

View File

@ -41,7 +41,7 @@ public class CustomExtraKeysPreference extends ListGroupPreference<String>
if (key_names != null)
{
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;
}

View File

@ -10,7 +10,100 @@ public class KeyValueParserTest
public KeyValueParserTest() {}
@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 flags='dim':'Foo'", KeyValue.makeStringKey("Foo", KeyValue.FLAG_SECONDARY));
@ -32,47 +125,17 @@ public class KeyValueParserTest
Utils.expect_error(":str flags='' ");
Utils.expect_error(":str flags='':");
Utils.expect_error(":str flags='':'");
}
@Test
public void parseChar() throws Exception
{
// Char
Utils.parse(":char symbol='a':b", KeyValue.makeCharKey('b', "a", 0));
Utils.parse(":char:b", KeyValue.makeCharKey('b', "b", 0));
}
@Test
public void parseKeyValue() throws Exception
{
Utils.parse("\'", KeyValue.makeStringKey("\'"));
Utils.parse("a", KeyValue.makeStringKey("a"));
Utils.parse("abc", KeyValue.makeStringKey("abc"));
Utils.parse("shift", KeyValue.getSpecialKeyByName("shift"));
Utils.expect_error("\'a");
}
@Test
public void parseMacro() throws Exception
{
Utils.parse(":macro symbol='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.expect_error(":macro:");
}
/** JUnit removes these functions from stacktraces. */
static class Utils
{
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)