1
0
Fork 0

Add Thai language support (#629)

* added Thai language

* the SoftKeyNumber now displays abbreviated letter list when there are too many letters on a single key

* updated the language validation rules to detect single letters in Asian languages

* added a 'no space between words' language YAML option

---------

Co-authored-by: sspanak <doftor.livain@gmail.com>
This commit is contained in:
Theppitak M. 2024-09-17 15:21:59 +07:00 committed by GitHub
parent e5b9beb84e
commit d5fc1fe4b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 19709 additions and 97 deletions

View file

@ -0,0 +1,17 @@
locale: th-TH
dictionaryFile: th-utf8.csv
abcString: กขค
hasSpaceBetweenWords: no
hasUpperCase: no
name: ภาษาไทย
layout:
- [SPECIAL] # 0
- [PUNCTUATION] # 1
- [ก, ข, ฃ, ค, ฅ, ฆ, ง, จ, ฉ] # 2
- [ช, ซ, ฌ, ญ, ฎ, ฏ, ฐ, ฑ, ฒ, ณ] # 3
- [ด, ต, ถ, ท, ธ, น] # 4
- [บ, ป, ผ, ฝ, พ, ฟ] # 5
- [ภ, ม, ย, ร, ล, ว] # 6
- [ศ, ษ, ส, ห, ฬ, อ, ฮ] # 7
- [ิ, ี, ึ, ื, ุ, ู, ั, ่, ้, ๊, ๋, ็, ์] # 8
- [ะ, า, โ, ไ, ใ, เ, แ, ำ, ๅ, ๆ, ฯ, ฤ, ฦ] # 9

File diff suppressed because it is too large Load diff

View file

@ -46,21 +46,19 @@ public class ModePredictive extends InputMode {
ModePredictive(SettingsStore settings, InputType inputType, TextField textField, Language lang) {
changeLanguage(lang);
defaultTextCase();
autoSpace = new AutoSpace(settings);
autoSpace = new AutoSpace(settings, lang).setLanguage(lang);
autoTextCase = new AutoTextCase(settings);
predictions = new Predictions(settings, textField);
this.settings = settings;
digitSequence = "";
predictions = new Predictions(settings, textField);
this.settings = settings;
if (inputType.isEmail()) {
KEY_CHARACTERS.add(new ArrayList<>(Characters.Email.get(0)));
KEY_CHARACTERS.add(new ArrayList<>(Characters.Email.get(1)));
}
changeLanguage(lang);
defaultTextCase();
}
@ -113,6 +111,8 @@ public class ModePredictive extends InputMode {
public void changeLanguage(Language language) {
super.changeLanguage(language);
autoSpace.setLanguage(language);
allowedTextCases.clear();
allowedTextCases.add(CASE_LOWER);
if (language.hasUpperCase()) {
@ -124,6 +124,10 @@ public class ModePredictive extends InputMode {
@Override
public boolean recompose(String word) {
if (!language.hasSpaceBetweenWords()) {
return false;
}
if (word == null || word.length() < 2 || word.contains(" ")) {
Logger.d(LOG_TAG, "Not recomposing invalid word: '" + word + "'");
textCase = CASE_CAPITALIZE;

View file

@ -2,6 +2,7 @@ package io.github.sspanak.tt9.ime.modes.helpers;
import io.github.sspanak.tt9.hacks.InputType;
import io.github.sspanak.tt9.ime.helpers.TextField;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.preferences.settings.SettingsStore;
import io.github.sspanak.tt9.util.Characters;
import io.github.sspanak.tt9.util.Text;
@ -12,9 +13,11 @@ public class AutoSpace {
private InputType inputType;
private TextField textField;
private String lastWord;
private boolean isLanguageWithSpaceBetweenWords;
public AutoSpace(SettingsStore settingsStore) {
public AutoSpace(SettingsStore settingsStore, Language language) {
settings = settingsStore;
isLanguageWithSpaceBetweenWords = true;
}
public AutoSpace setInputType(InputType inputType) {
@ -32,6 +35,11 @@ public class AutoSpace {
return this;
}
public AutoSpace setLanguage(Language language) {
isLanguageWithSpaceBetweenWords = language != null && language.hasSpaceBetweenWords();
return this;
}
public AutoSpace setLastSequence() {
return this;
}
@ -40,11 +48,13 @@ public class AutoSpace {
* shouldAddAutoSpace
* When the "auto-space" settings is enabled, this determines whether to automatically add a space
* at the end of a sentence or after accepting a suggestion. This allows faster typing, without
* pressing space.
*
* See the helper functions for the list of rules.
* pressing space. See the helper functions for the list of rules.
*/
public boolean shouldAddAutoSpace(boolean isWordAcceptedManually, int nextKey) {
if (!isLanguageWithSpaceBetweenWords) {
return false;
}
String previousChars = textField.getStringBeforeCursor(2);
Text nextChars = textField.getTextAfterCursor(2);
@ -114,7 +124,8 @@ public class AutoSpace {
*/
public boolean shouldDeletePrecedingSpace() {
return
settings.getAutoSpace()
isLanguageWithSpaceBetweenWords
&& settings.getAutoSpace()
&& (
lastWord.equals(".")
|| lastWord.equals(",")

View file

@ -13,6 +13,7 @@ abstract public class Language {
protected String dictionaryFile;
protected Locale locale = Locale.ROOT;
protected String name;
protected boolean hasSpaceBetweenWords = true;
protected boolean hasUpperCase = true;
@ -51,6 +52,10 @@ abstract public class Language {
return name;
}
final public boolean hasSpaceBetweenWords() {
return hasSpaceBetweenWords;
}
final public boolean hasUpperCase() {
return hasUpperCase;
}

View file

@ -20,6 +20,7 @@ public class LanguageDefinition {
public String abcString = "";
public String dictionaryFile = "";
public boolean hasSpaceBetweenWords = true;
public boolean hasUpperCase = true;
public ArrayList<ArrayList<String>> layout = new ArrayList<>();
public String locale = "";
@ -83,27 +84,14 @@ public class LanguageDefinition {
@NonNull
private static LanguageDefinition parse(ArrayList<String> yaml) {
LanguageDefinition definition = new LanguageDefinition();
String value;
value = getPropertyFromYaml(yaml, "abcString");
definition.abcString = value != null ? value : definition.abcString;
value = getPropertyFromYaml(yaml, "dictionaryFile");
definition.dictionaryFile = value != null ? value : definition.dictionaryFile;
value = getPropertyFromYaml(yaml, "locale");
definition.locale = value != null ? value : definition.locale;
value = getPropertyFromYaml(yaml, "name");
definition.name = value != null ? value : definition.name;
definition.abcString = getPropertyFromYaml(yaml, "abcString", definition.abcString);
definition.dictionaryFile = getPropertyFromYaml(yaml, "dictionaryFile", definition.dictionaryFile);
definition.hasSpaceBetweenWords = getPropertyFromYaml(yaml, "hasSpaceBetweenWords", definition.hasSpaceBetweenWords);
definition.hasUpperCase = getPropertyFromYaml(yaml, "hasUpperCase", definition.hasUpperCase);
definition.layout = getLayoutFromYaml(yaml);
value = getPropertyFromYaml(yaml, "hasUpperCase");
if (value != null) {
value = value.toLowerCase();
definition.hasUpperCase = value.equals("true") || value.equals("on") || value.equals("yes") || value.equals("y");
}
definition.locale = getPropertyFromYaml(yaml, "locale", definition.locale);
definition.name = getPropertyFromYaml(yaml, "name", definition.name);
return definition;
}
@ -112,10 +100,10 @@ public class LanguageDefinition {
/**
* getPropertyFromYaml
* Finds "property" in the "yaml" and returns its value.
* Optional properties are allowed. NULL will be returned when they are missing.
* Optional properties are allowed. If the property is not found, "defaultValue" will be returned.
*/
@Nullable
private static String getPropertyFromYaml(ArrayList<String> yaml, String property) {
private static String getPropertyFromYaml(ArrayList<String> yaml, String property, String defaultValue) {
for (String line : yaml) {
line = line.replaceAll("#.+$", "").trim();
String[] parts = line.split(":");
@ -128,7 +116,22 @@ public class LanguageDefinition {
}
}
return null;
return defaultValue;
}
/**
* The boolean variant of getPropertyFromYaml. It returns true if the property is found and is:
* "true", "on", "yes" or "y".
*/
private static boolean getPropertyFromYaml(ArrayList<String> yaml, String property, boolean defaultValue) {
String value = getPropertyFromYaml(yaml, property, null);
if (value == null) {
return defaultValue;
}
value = value.toLowerCase();
return value.equals("true") || value.equals("on") || value.equals("yes") || value.equals("y");
}

View file

@ -2,6 +2,7 @@ package io.github.sspanak.tt9.languages;
public class LanguageKind {
public static boolean isArabic(Language language) { return language != null && language.getKeyCharacters(3).contains("ا"); }
public static boolean isBulgarian(Language language) { return language != null && language.getKeyCharacters(4).contains("ѝ"); }
public static boolean isCyrillic(Language language) { return language != null && language.getKeyCharacters(2).contains("а"); }
public static boolean isHebrew(Language language) { return language != null && language.getKeyCharacters(3).contains("א"); }
public static boolean isGreek(Language language) { return language != null && language.getKeyCharacters(2).contains("α"); }

View file

@ -29,6 +29,7 @@ public class NaturalLanguage extends Language implements Comparable<NaturalLangu
NaturalLanguage lang = new NaturalLanguage();
lang.abcString = definition.abcString.isEmpty() ? null : definition.abcString;
lang.dictionaryFile = definition.getDictionaryFile();
lang.hasSpaceBetweenWords = definition.hasSpaceBetweenWords;
lang.hasUpperCase = definition.hasUpperCase;
lang.name = definition.name.isEmpty() ? lang.name : definition.name;
lang.setLocale(definition);

View file

@ -22,6 +22,7 @@ public class SettingsStore extends SettingsUI {
public final static byte SLOW_QUERY_TIME = 50; // ms
public final static int SOFT_KEY_DOUBLE_CLICK_DELAY = 500; // ms
public final static int SOFT_KEY_REPEAT_DELAY = 40; // ms
public final static int SOFT_KEY_TITLE_MAX_CHARS = 5;
public final static int SOFT_KEY_TITLE_SIZE = 18; // sp
public final static float SOFT_KEY_COMPLEX_LABEL_TITLE_RELATIVE_SIZE = 0.55f;
public final static float SOFT_KEY_COMPLEX_LABEL_ARABIC_TITLE_RELATIVE_SIZE = 0.72f;

View file

@ -2,11 +2,16 @@ package io.github.sspanak.tt9.ui.main.keys;
import android.content.Context;
import android.util.AttributeSet;
import android.util.SparseArray;
import android.view.KeyEvent;
import androidx.annotation.NonNull;
import java.util.ArrayList;
import java.util.Locale;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.ime.TraditionalT9;
import io.github.sspanak.tt9.ime.helpers.Key;
import io.github.sspanak.tt9.ime.modes.InputMode;
import io.github.sspanak.tt9.languages.Language;
@ -14,8 +19,34 @@ import io.github.sspanak.tt9.languages.LanguageKind;
import io.github.sspanak.tt9.preferences.settings.SettingsStore;
import io.github.sspanak.tt9.ui.Vibration;
import io.github.sspanak.tt9.util.Logger;
import io.github.sspanak.tt9.util.TextTools;
public class SoftKeyNumber extends SoftKey {
private final static SparseArray<Integer> NUMBERS = new SparseArray<Integer>() {{
put(R.id.soft_key_0, 0);
put(R.id.soft_key_1, 1);
put(R.id.soft_key_2, 2);
put(R.id.soft_key_3, 3);
put(R.id.soft_key_4, 4);
put(R.id.soft_key_5, 5);
put(R.id.soft_key_6, 6);
put(R.id.soft_key_7, 7);
put(R.id.soft_key_8, 8);
put(R.id.soft_key_9, 9);
}};
private final static SparseArray<Integer> UPSIDE_DOWN_NUMBERS = new SparseArray<Integer>() {{
put(1, 7);
put(2, 8);
put(3, 9);
put(7, 1);
put(8, 2);
put(9, 3);
}};
private static final String PUNCTUATION_LABEL = ",:-)";
public SoftKeyNumber(Context context) {
super(context);
}
@ -28,6 +59,7 @@ public class SoftKeyNumber extends SoftKey {
super(context, attrs, defStyleAttr);
}
@Override
protected void handleHold() {
preventRepeat();
@ -42,6 +74,7 @@ public class SoftKeyNumber extends SoftKey {
tt9.onKeyUp(keyCode, new KeyEvent(KeyEvent.ACTION_UP, keyCode));
}
@Override
protected boolean handleRelease() {
int keyCode = Key.numberToCode(getUpsideDownNumber(getId()));
@ -55,6 +88,23 @@ public class SoftKeyNumber extends SoftKey {
return true;
}
protected int getNumber(int keyId) {
return NUMBERS.get(keyId, -1);
}
protected int getUpsideDownNumber(int keyId) {
int number = getNumber(keyId);
if (tt9 == null || !tt9.getSettings().getUpsideDownKeys()) {
return number;
}
return UPSIDE_DOWN_NUMBERS.get(number, number);
}
@Override
protected String getTitle() {
int number = getNumber(getId());
@ -68,6 +118,7 @@ public class SoftKeyNumber extends SoftKey {
}
}
@Override
protected String getSubTitle() {
if (tt9 == null) {
@ -76,88 +127,105 @@ public class SoftKeyNumber extends SoftKey {
int number = getNumber(getId());
// 0
if (number == 0) {
if (tt9.isNumericModeSigned()) {
return "+/-";
} else if (tt9.isNumericModeStrict()) {
return null;
} else if (tt9.isInputModeNumeric()) {
return "+";
} else {
complexLabelSubTitleSize = 1;
return "";
}
switch (number) {
case 0:
return getSpecialCharList(tt9);
case 1:
return tt9.isNumericModeStrict() ? null : PUNCTUATION_LABEL;
default:
return getKeyCharList(tt9, number);
}
}
// 1
if (number == 1) {
return tt9.isNumericModeStrict() ? null : ",:-)";
}
// no other special labels in 123 mode
if (tt9.isInputModeNumeric()) {
private String getSpecialCharList(@NonNull TraditionalT9 tt9) {
if (tt9.isNumericModeSigned()) {
return "+/-";
} else if (tt9.isNumericModeStrict()) {
return null;
} else if (tt9.isInputModeNumeric()) {
return "+";
} else {
complexLabelSubTitleSize = 1;
return "";
}
}
private String getKeyCharList(@NonNull TraditionalT9 tt9, int number) {
if (tt9.isInputModeNumeric()) {
return null; // no special labels in 123 mode
}
// 2-9
Language language = tt9.getLanguage();
if (language == null) {
Logger.d("SoftKeyNumber.getLabel", "Cannot generate a label when the language is NULL.");
return "";
return null;
}
boolean isLatinBased = LanguageKind.isLatinBased(language);
boolean isGreekBased = LanguageKind.isGreek(language);
StringBuilder sb = new StringBuilder();
ArrayList<String> chars = language.getKeyCharacters(number);
for (int i = 0; sb.length() < 5 && i < chars.size(); i++) {
String currentLetter = chars.get(i);
if (
(isLatinBased && currentLetter.charAt(0) > 'z')
|| (isGreekBased && (currentLetter.charAt(0) < 'α' || currentLetter.charAt(0) > 'ω'))
) {
// As suggested by the community, there is no need to display the accented letters.
// People are used to seeing just A-Z.
boolean isBulgarian = LanguageKind.isBulgarian(language);
boolean isGreek = LanguageKind.isGreek(language);
boolean isLatinBased = LanguageKind.isLatinBased(language);
boolean isUkrainian = LanguageKind.isUkrainian(language);
boolean isUppercase = tt9.getTextCase() == InputMode.CASE_UPPER;
if (
isBulgarian
|| isGreek
|| isLatinBased
|| (isUkrainian && number == 2)
|| chars.size() < SettingsStore.SOFT_KEY_TITLE_MAX_CHARS) {
return getDefaultCharList(chars, language.getLocale(), isGreek, isLatinBased, isUppercase);
} else {
return abbreviateCharList(chars, language.getLocale(), isUppercase);
}
}
/**
* Joins the key characters into a single string, skipping accented characters
* when neccessary
*/
private String getDefaultCharList(ArrayList<String> chars, Locale locale, boolean isGreek, boolean isLatinBased, boolean isUppercase) {
StringBuilder sb = new StringBuilder();
for (String currentLetter : chars) {
if (shouldSkipAccents(currentLetter.charAt(0), isGreek, isLatinBased)) {
continue;
}
sb.append(
tt9.getTextCase() == InputMode.CASE_UPPER ? currentLetter.toUpperCase(language.getLocale()) : currentLetter
isUppercase ? currentLetter.toUpperCase(locale) : currentLetter
);
}
return sb.toString();
}
protected int getNumber(int keyId) {
if (keyId == R.id.soft_key_0) return 0;
if (keyId == R.id.soft_key_1) return 1;
if (keyId == R.id.soft_key_2) return 2;
if (keyId == R.id.soft_key_3) return 3;
if (keyId == R.id.soft_key_4) return 4;
if (keyId == R.id.soft_key_5) return 5;
if (keyId == R.id.soft_key_6) return 6;
if (keyId == R.id.soft_key_7) return 7;
if (keyId == R.id.soft_key_8) return 8;
if (keyId == R.id.soft_key_9) return 9;
return -1;
/**
* In some languages there are many characters for a single key. Naturally, they can not all fit
* on one key. As suggested by the community, we could display them as "A-Z".
* @see <a href="https://github.com/sspanak/tt9/issues/628">Issue #628</a>
*/
private String abbreviateCharList(ArrayList<String> chars, Locale locale, boolean isUppercase) {
boolean containsCombiningChars = TextTools.isCombining(chars.get(0)) || TextTools.isCombining(chars.get(chars.size() - 1));
return
(isUppercase ? chars.get(0).toUpperCase(locale) : chars.get(0))
+ (containsCombiningChars ? " " : "")
+ (isUppercase ? chars.get(chars.size() - 1).toUpperCase(locale) : chars.get(chars.size() - 1));
}
protected int getUpsideDownNumber(int keyId) {
int number = getNumber(keyId);
if (tt9 != null && tt9.getSettings().getUpsideDownKeys()) {
if (number == 1) return 7;
if (number == 2) return 8;
if (number == 3) return 9;
if (number == 7) return 1;
if (number == 8) return 2;
if (number == 9) return 3;
}
return number;
/**
* As suggested by the community, there is no need to display the accented letters.
* People are used to seeing just "ABC", "DEF", etc.
*/
private boolean shouldSkipAccents(char currentLetter, boolean isGreek, boolean isLatinBased) {
return
currentLetter == 'ѝ'
|| currentLetter == 'ґ'
|| (isLatinBased && currentLetter > 'z')
|| (isGreek && (currentLetter < 'α' || currentLetter > 'ω'));
}
}

View file

@ -8,16 +8,22 @@ import java.util.regex.Pattern;
public class TextTools {
private static final Pattern containsOtherThan1 = Pattern.compile("[02-9]");
private static final Pattern combiningString = Pattern.compile("^\\p{M}+$");
private static final Pattern nextIsPunctuation = Pattern.compile("^\\p{Punct}");
private static final Pattern nextToWord = Pattern.compile("\\b$");
private static final Pattern previousIsLetter = Pattern.compile("\\p{L}$");
private static final Pattern startOfSentence = Pattern.compile("(?<!\\.)(^|[.?!؟¿¡])\\s+$");
public static boolean containsOtherThan1(String str) {
return str != null && containsOtherThan1.matcher(str).find();
}
public static boolean isCombining(String str) {
return str != null && combiningString.matcher(str).find();
}
public static boolean isGraphic(String str) {
if (str == null || str.isEmpty()) {

View file

@ -12,7 +12,7 @@ static def validateDictionaryWord(String word, int lineNumber, String validChara
errors += "${errorMsgPrefix}. Found a garbage word: '${word}' on line ${lineNumber}.\n"
}
if (word.matches("^.\$")) {
if (word.matches("^(.|\\p{L}\\p{M}?)\$")) {
errorCount++
errors += "${errorMsgPrefix}. Found a single letter: '${word}' on line ${lineNumber}. Only uppercase single letters are allowed. The rest of the alphabet will be added automatically.\n"
}
@ -65,6 +65,7 @@ static def parseLanguageFile(File languageFile, String dictionariesDir) {
line.matches("^[a-zA-Z].*")
&& !line.startsWith("abcString")
&& !line.startsWith("dictionaryFile")
&& !line.startsWith("hasSpaceBetweenWords")
&& !line.startsWith("hasUpperCase")
&& !line.startsWith("layout")
&& !line.startsWith("locale")
@ -77,10 +78,14 @@ static def parseLanguageFile(File languageFile, String dictionariesDir) {
errorMsg += "Language '${languageFile.name}' is invalid. Found unknown property: '${property}'.\n"
}
if (line.startsWith("hasUpperCase") && !line.endsWith("yes") && !line.endsWith("no")) {
if (
(line.startsWith("hasUpperCase") || line.startsWith("hasSpaceBetweenWords"))
&& !line.endsWith("yes") && !line.endsWith("no")
) {
def property = line.replaceAll(":.*\$", "")
def invalidVal = line.replace("hasUpperCase:", "").trim()
errorCount++
errorMsg += "Language '${languageFile.name}' is invalid. Unrecognized 'hasUpperCase' value: '${invalidVal}'. Only 'yes' and 'no' are allowed.\n"
errorMsg += "Language '${languageFile.name}' is invalid. Unrecognized '${property}' value: '${invalidVal}'. Only 'yes' and 'no' are allowed.\n"
}
if (line.startsWith("layout")) {