From 9f22e53a3ba8f064e69e3a84c371a7f29ee9e05c Mon Sep 17 00:00:00 2001 From: Jules Aguillon Date: Sun, 29 Sep 2024 21:58:22 +0200 Subject: [PATCH] Add complex keys (#774) This allows to add new kinds of keys that need more data without making KeyValue's footprint bigger for common keys. This changes the [_symbol] field into [_payload], which holds the same as the previous field for more common keys but can hold bigger objects for keys of the new "Complex" kind. This also adds a complex key: String keys with a symbol different than the outputted string. Unit tests are added as the Java language is not helpful in making robust code. --- build.gradle | 11 +- doc/Possible-key-values.md | 27 +++- srcs/juloo.keyboard2/Config.java | 3 +- srcs/juloo.keyboard2/KeyEventHandler.java | 11 ++ srcs/juloo.keyboard2/KeyValue.java | 142 ++++++++++++++--- srcs/juloo.keyboard2/KeyValueParser.java | 150 ++++++++++++++++++ .../prefs/CustomExtraKeysPreference.java | 2 +- test/juloo.keyboard2/KeyValueParserTest.java | 54 +++++++ test/juloo.keyboard2/KeyValueTest.java | 16 ++ 9 files changed, 390 insertions(+), 26 deletions(-) create mode 100644 srcs/juloo.keyboard2/KeyValueParser.java create mode 100644 test/juloo.keyboard2/KeyValueParserTest.java create mode 100644 test/juloo.keyboard2/KeyValueTest.java diff --git a/build.gradle b/build.gradle index de9efde..0cb6160 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,10 @@ plugins { id 'com.android.application' version '8.1.1' } +dependencies { + testImplementation "junit:junit:4.13.2" +} + android { namespace 'juloo.keyboard2' compileSdk 34 @@ -21,6 +25,10 @@ android { res.srcDirs = ['res', 'build/generated-resources'] assets.srcDirs = ['assets'] } + + test { + java.srcDirs = ['test'] + } } signingConfigs { @@ -84,9 +92,6 @@ android { } } -dependencies { -} - tasks.register('buildKeyboardFont') { println "\nBuilding assets/special_font.ttf" mkdir "$buildDir" diff --git a/doc/Possible-key-values.md b/doc/Possible-key-values.md index 57d5a90..c420f8d 100644 --- a/doc/Possible-key-values.md +++ b/doc/Possible-key-values.md @@ -119,7 +119,7 @@ Keys ending in `_placeholder` are normally hidden unless the Fn key is pressed. `ole`, `ole_placeholder`, `meteg`, `meteg_placeholder` -## Unexpected Keyboard specific +## Keyboard behavior keys Value | Meaning :--------------------- | :------ `config` | Gear icon; opens Unexpected Keyboard settings. @@ -148,3 +148,28 @@ These keys are known to do nothing. These keys are normally hidden unless the Fn modifier is activated. `f11_placeholder` | `f12_placeholder` + +## Complex keys + +More complex keys are of this form: + +``` +: : +``` + +Where `` is one of the kinds documented below and `` is a +space separated list of attributes. `` depends on the ``. + +Attributes are: +- `symbol='Sym'` is the symbol to be shown on the keyboard. +- `flags=''` is a collection of flags that change the behavior of the key. + `` 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. `` is a string wrapped +in single-quotes (`'`), escaping of other single quotes is allowed with `\'`. + +For example: `:str symbol='Sym':'Output string'` diff --git a/srcs/juloo.keyboard2/Config.java b/srcs/juloo.keyboard2/Config.java index 5e60815..6d77f6f 100644 --- a/srcs/juloo.keyboard2/Config.java +++ b/srcs/juloo.keyboard2/Config.java @@ -210,8 +210,7 @@ public final class Config KeyValue action_key() { // Update the name to avoid caching in KeyModifier - return (actionLabel == null) ? null : - KeyValue.getKeyByName("action").withSymbol(actionLabel); + return (actionLabel == null) ? null : KeyValue.makeActionKey(actionLabel); } /** Update the layout according to the configuration. diff --git a/srcs/juloo.keyboard2/KeyEventHandler.java b/srcs/juloo.keyboard2/KeyEventHandler.java index 087ac5b..c54ffa5 100644 --- a/srcs/juloo.keyboard2/KeyEventHandler.java +++ b/srcs/juloo.keyboard2/KeyEventHandler.java @@ -97,6 +97,7 @@ public final class KeyEventHandler _recv.set_compose_pending(true); break; case Cursor_move: move_cursor(key.getCursorMove()); break; + case Complex: send_complex_key(key.getComplexKind(), key.getComplex()); break; } update_meta_state(old_mods); } @@ -215,6 +216,16 @@ public final class KeyEventHandler conn.performContextMenuAction(id); } + void send_complex_key(KeyValue.Complex.Kind kind, KeyValue.Complex val) + { + switch (kind) + { + case StringWithSymbol: + send_text(((KeyValue.Complex.StringWithSymbol)val).str); + break; + } + } + @SuppressLint("InlinedApi") void handle_editing_key(KeyValue.Editing ev) { diff --git a/srcs/juloo.keyboard2/KeyValue.java b/srcs/juloo.keyboard2/KeyValue.java index 2a329df..8adacf0 100644 --- a/srcs/juloo.keyboard2/KeyValue.java +++ b/srcs/juloo.keyboard2/KeyValue.java @@ -91,7 +91,8 @@ public final class KeyValue implements Comparable { Char, String, Keyevent, Event, Compose_pending, Hangul_initial, Hangul_medial, Modifier, Editing, Placeholder, - Cursor_move // Value is encoded as a 16-bit integer + Cursor_move, // Value is encoded as a 16-bit integer. + Complex, // [_payload] is a [KeyValue.Complex], value is [Complex.Kind]. } private static final int FLAGS_OFFSET = 19; @@ -129,7 +130,13 @@ public final class KeyValue implements Comparable check((((Kind.values().length - 1) << KIND_OFFSET) & ~KIND_BITS) == 0); } - private final String _symbol; + /** + * The symbol that is rendered on the keyboard as a [String]. + * Except for keys of kind: + * - [String], this is also the string to output. + * - [Complex], this is an instance of [KeyValue.Complex]. + */ + private final Object _payload; /** This field encodes three things: Kind, flags and value. */ private final int _code; @@ -153,7 +160,9 @@ public final class KeyValue implements Comparable When [getKind() == Kind.String], also the string to send. */ public String getString() { - return _symbol; + if (getKind() == Kind.Complex) + return ((Complex)_payload).getSymbol(); + return (String)_payload; } /** Defined only when [getKind() == Kind.Char]. */ @@ -211,25 +220,32 @@ public final class KeyValue implements Comparable return (short)(_code & VALUE_BITS); } + /** Defined only when [getKind() == Kind.Complex]. */ + public Complex getComplex() + { + return (Complex)_payload; + } + + /** Defined only when [getKind() == Kind.Complex]. */ + public Complex.Kind getComplexKind() + { + return Complex.Kind.values()[(_code & VALUE_BITS)]; + } + /* Update the char and the symbol. */ public KeyValue withChar(char c) { return new KeyValue(String.valueOf(c), Kind.Char, c, getFlags()); } - public KeyValue withSymbol(String s) - { - return new KeyValue(s, (_code & KIND_BITS), (_code & VALUE_BITS), getFlags()); - } - public KeyValue withKeyevent(int code) { - return new KeyValue(_symbol, Kind.Keyevent, code, getFlags()); + return new KeyValue(getString(), Kind.Keyevent, code, getFlags()); } public KeyValue withFlags(int f) { - return new KeyValue(_symbol, (_code & KIND_BITS), (_code & VALUE_BITS), f); + return new KeyValue(getString(), (_code & KIND_BITS), (_code & VALUE_BITS), f); } @Override @@ -247,7 +263,9 @@ public final class KeyValue implements Comparable d = _code - snd._code; if (d != 0) return d; - return _symbol.compareTo(snd._symbol); + if (getKind() == Kind.Complex) + return ((Complex)_payload).compareTo((Complex)snd._payload); + return ((String)_payload).compareTo((String)snd._payload); } /** Type-safe alternative to [equals]. */ @@ -255,24 +273,36 @@ public final class KeyValue implements Comparable { if (snd == null) return false; - return _symbol.equals(snd._symbol) && _code == snd._code; + return _code == snd._code && _payload.equals(snd._payload); } @Override public int hashCode() { - return _symbol.hashCode() + _code; + return _payload.hashCode() + _code; } - public KeyValue(String s, int kind, int value, int flags) + public String toString() { - _symbol = s; + int value = _code & VALUE_BITS; + return "[KeyValue " + getKind().toString() + "+" + getFlags() + "+" + value + " \"" + getString() + "\"]"; + } + + private KeyValue(Object p, int kind, int value, int flags) + { + _payload = p; _code = (kind & KIND_BITS) | (flags & FLAGS_BITS) | (value & VALUE_BITS); } - public KeyValue(String s, Kind k, int v, int f) + public KeyValue(Complex p, Complex.Kind value, int flags) { - this(s, (k.ordinal() << KIND_OFFSET), v, f); + this((Object)p, (Kind.Complex.ordinal() << KIND_OFFSET), value.ordinal(), + flags); + } + + public KeyValue(String p, Kind k, int v, int f) + { + this(p, (k.ordinal() << KIND_OFFSET), v, f); } private static KeyValue charKey(String symbol, char c, int flags) @@ -397,6 +427,11 @@ public final class KeyValue implements Comparable return KeyValue.makeCharKey((char)precomposed); } + public static KeyValue makeActionKey(String symbol) + { + return eventKey(symbol, Event.ACTION, FLAG_SMALLER_FONT); + } + /** Make a key that types a string. A char key is returned for a string of length 1. */ public static KeyValue makeStringKey(String str, int flags) @@ -407,12 +442,36 @@ public final class KeyValue implements Comparable return new KeyValue(str, Kind.String, 0, flags | FLAG_SMALLER_FONT); } + public static KeyValue makeStringKeyWithSymbol(String str, String symbol, int flags) + { + return new KeyValue(new Complex.StringWithSymbol(str, symbol), + Complex.Kind.StringWithSymbol, flags); + } + /** Make a modifier key for passing to [KeyModifier]. */ public static KeyValue makeInternalModifier(Modifier mod) { 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. + */ public static KeyValue getKeyByName(String name) { switch (name) @@ -599,8 +658,8 @@ public final class KeyValue implements Comparable case "ㅍ": return makeHangulInitial("ㅍ", 17); case "ㅎ": return makeHangulInitial("ㅎ", 18); - /* Fallback to a string key that types its name */ - default: return makeStringKey(name); + /* The key is not one of the special ones. */ + default: return parseKeyDefinition(name); } } @@ -610,4 +669,49 @@ public final class KeyValue implements Comparable if (!b) throw new RuntimeException("Assertion failure"); } + + public static abstract class Complex + { + public abstract String getSymbol(); + + /** [compareTo] can assume that [snd] is an instance of the same class. */ + public abstract int compareTo(Complex snd); + + public boolean equals(Object snd) + { + if (snd instanceof Complex) + return compareTo((Complex)snd) == 0; + return false; + } + + /** [hashCode] will be called on this class. */ + + /** The kind is stored in the [value] field of the key. */ + public static enum Kind + { + StringWithSymbol, + } + + public static final class StringWithSymbol extends Complex + { + public final String str; + private final String _symbol; + + public StringWithSymbol(String _str, String _sym) + { + str = _str; + _symbol = _sym; + } + + public String getSymbol() { return _symbol; } + + public int compareTo(Complex _snd) + { + StringWithSymbol snd = (StringWithSymbol)_snd; + int d = str.compareTo(snd.str); + if (d != 0) return d; + return _symbol.compareTo(snd._symbol); + } + } + }; } diff --git a/srcs/juloo.keyboard2/KeyValueParser.java b/srcs/juloo.keyboard2/KeyValueParser.java new file mode 100644 index 0000000..178046e --- /dev/null +++ b/srcs/juloo.keyboard2/KeyValueParser.java @@ -0,0 +1,150 @@ +package juloo.keyboard2; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** +Parse a key definition. The syntax for a key definition is: +- [:(kind) (attributes):(payload)]. +- If [str] doesn't start with a [:] character, it is interpreted as an + arbitrary string key. + +[(kind)] specifies the kind of the key, it can be: +- [str]: An arbitrary string key. The payload is the string to output when + typed and is quoted by single quotes ([']). The payload can contain single + quotes if they are escaped with a backslash ([\']). + +The [(attributes)] part is a space-separated list of attributes, all optional, +of the form: [attrname='attrvalue']. + +Attributes can be: +- [flags]: Add flags that change the behavior of the key. + Value is a coma separated list of: + - [dim]: Make the symbol dimmer on the keyboard. + - [small]: Make the symbol smaller on the keyboard. +- [symbol]: Specify the symbol that is rendered on the keyboard. + It can contain single quotes if they are escaped: ([\']). + +Examples: +- [:str flags=dim,small symbol='MyKey':'My arbitrary string']. +- [:str:'My arbitrary string']. + +*/ +public final class KeyValueParser +{ + static Pattern START_PAT; + static Pattern ATTR_PAT; + static Pattern QUOTED_PAT; + static Pattern PAYLOAD_START_PAT; + + static public KeyValue parse(String str) throws ParseError + { + String symbol = null; + int flags = 0; + init(); + // Kind + Matcher m = START_PAT.matcher(str); + if (!m.lookingAt()) + parseError("Expected kind, for example \":str ...\".", m); + String kind = m.group(1); + // Attributes + 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); + switch (kind) + { + case "str": + String payload = parseSingleQuotedString(m); + if (symbol == null) + return KeyValue.makeStringKey(payload, flags); + return KeyValue.makeStringKeyWithSymbol(payload, symbol, 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 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; + } + + 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 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*:"); + } + + static void parseError(String msg, Matcher m) throws ParseError + { + parseError(msg, m, m.regionStart()); + } + + static void parseError(String msg, Matcher m, int i) throws ParseError + { + StringBuilder msg_ = new StringBuilder("Syntax error"); + try + { + char c = m.group(0).charAt(0); + msg_.append(" at character '").append(c).append("'"); + } catch (IllegalStateException _e) {} + msg_.append(" at position "); + msg_.append(i); + msg_.append(": "); + msg_.append(msg); + throw new ParseError(msg_.toString()); + } + + public static class ParseError extends Exception + { + public ParseError(String msg) { super(msg); } + }; +} diff --git a/srcs/juloo.keyboard2/prefs/CustomExtraKeysPreference.java b/srcs/juloo.keyboard2/prefs/CustomExtraKeysPreference.java index cf47d46..fda07ec 100644 --- a/srcs/juloo.keyboard2/prefs/CustomExtraKeysPreference.java +++ b/srcs/juloo.keyboard2/prefs/CustomExtraKeysPreference.java @@ -40,7 +40,7 @@ public class CustomExtraKeysPreference extends ListGroupPreference if (key_names != null) { for (String key_name : key_names) - kvs.put(KeyValue.makeStringKey(key_name), KeyboardData.PreferredPos.DEFAULT); + kvs.put(KeyValue.parseKeyDefinition(key_name), KeyboardData.PreferredPos.DEFAULT); } return kvs; } diff --git a/test/juloo.keyboard2/KeyValueParserTest.java b/test/juloo.keyboard2/KeyValueParserTest.java new file mode 100644 index 0000000..900ae3d --- /dev/null +++ b/test/juloo.keyboard2/KeyValueParserTest.java @@ -0,0 +1,54 @@ +package juloo.keyboard2; + +import juloo.keyboard2.KeyValue; +import juloo.keyboard2.KeyValueParser; +import org.junit.Test; +import static org.junit.Assert.*; + +public class KeyValueParserTest +{ + public KeyValueParserTest() {} + + @Test + public void parse() throws Exception + { + Utils.parse(":str:'Foo'", KeyValue.makeStringKey("Foo")); + Utils.parse(":str flags='dim':'Foo'", KeyValue.makeStringKey("Foo", KeyValue.FLAG_SECONDARY)); + Utils.parse(":str symbol='Symbol':'Foo'", KeyValue.makeStringKeyWithSymbol("Foo", "Symbol", 0)); + Utils.parse(":str symbol='Symbol' flags='dim':'Foo'", KeyValue.makeStringKeyWithSymbol("Foo", "Symbol", KeyValue.FLAG_SECONDARY)); + Utils.parse(":str flags='dim,small':'Foo'", KeyValue.makeStringKey("Foo", KeyValue.FLAG_SECONDARY | KeyValue.FLAG_SMALLER_FONT)); + Utils.parse(":str flags=',,':'Foo'", KeyValue.makeStringKey("Foo")); // Unintentional + Utils.expect_error(":unknown:Foo"); // Unknown kind + Utils.expect_error(":str:Foo"); // Unquoted string + Utils.expect_error(":str flags:'Foo'"); // Malformed flags + Utils.expect_error(":str flags=dim:'Foo'"); // Unquoted flags + Utils.expect_error(":str unknown='foo':'Foo'"); // Unknown flags + // Unterminated + Utils.expect_error(":str"); + Utils.expect_error(":str "); + 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='':'"); + } + + /** 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)); + } + + static void expect_error(String key_descr) + { + try + { + fail("Expected failure but got " + KeyValueParser.parse(key_descr)); + } + catch (KeyValueParser.ParseError e) {} + } + } +} diff --git a/test/juloo.keyboard2/KeyValueTest.java b/test/juloo.keyboard2/KeyValueTest.java new file mode 100644 index 0000000..1fde92b --- /dev/null +++ b/test/juloo.keyboard2/KeyValueTest.java @@ -0,0 +1,16 @@ +package juloo.keyboard2; + +import juloo.keyboard2.KeyValue; +import org.junit.Test; +import static org.junit.Assert.*; + +public class KeyValueTest +{ + public KeyValueTest() {} + + @Test + public void equals() + { + assertEquals(KeyValue.makeStringKeyWithSymbol("Foo", "Symbol", 0), KeyValue.makeStringKeyWithSymbol("Foo", "Symbol", 0)); + } +}