* readjusted the font size of the suggestions and on the Settings screen * moved the keypad shortcuts to a separate screen * added bold+italic visual hint when fuzzy word filtering is on * updated documentation
579 lines
18 KiB
Java
579 lines
18 KiB
Java
package io.github.sspanak.tt9.ime.modes;
|
|
|
|
import android.os.Handler;
|
|
import android.os.Looper;
|
|
import android.os.Message;
|
|
import android.view.inputmethod.EditorInfo;
|
|
import android.view.inputmethod.InputConnection;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.regex.Pattern;
|
|
|
|
import io.github.sspanak.tt9.Logger;
|
|
import io.github.sspanak.tt9.db.DictionaryDb;
|
|
import io.github.sspanak.tt9.ime.EmptyDatabaseWarning;
|
|
import io.github.sspanak.tt9.ime.InputFieldHelper;
|
|
import io.github.sspanak.tt9.languages.Language;
|
|
import io.github.sspanak.tt9.languages.Punctuation;
|
|
import io.github.sspanak.tt9.preferences.SettingsStore;
|
|
|
|
public class ModePredictive extends InputMode {
|
|
private final EmptyDatabaseWarning emptyDbWarning;
|
|
private final SettingsStore settings;
|
|
|
|
public int getId() { return MODE_PREDICTIVE; }
|
|
|
|
private String digitSequence = "";
|
|
private String lastAcceptedWord = "";
|
|
private String lastAcceptedSequence = "";
|
|
|
|
// stem filter
|
|
private boolean isStemFuzzy = false;
|
|
private String stem = "";
|
|
|
|
// async suggestion handling
|
|
private Language currentLanguage = null;
|
|
private String currentInputFieldWord = "";
|
|
private static Handler handleSuggestionsExternal;
|
|
|
|
// auto text case selection
|
|
private final Pattern endOfSentenceRegex = Pattern.compile("(?<!\\.)[.?!]\\s*$");
|
|
|
|
// punctuation/emoji
|
|
private final Pattern containsOnly1Regex = Pattern.compile("^1+$");
|
|
private final String maxEmojiSequence;
|
|
|
|
|
|
ModePredictive(SettingsStore settings) {
|
|
allowedTextCases.add(CASE_LOWER);
|
|
allowedTextCases.add(CASE_CAPITALIZE);
|
|
allowedTextCases.add(CASE_UPPER);
|
|
|
|
emptyDbWarning = new EmptyDatabaseWarning(settings);
|
|
this.settings = settings;
|
|
|
|
// digitSequence limiter when selecting emoji
|
|
// "11" = Emoji level 0, "111" = Emoji level 1,... up to the maximum amount of 1s
|
|
StringBuilder maxEmojiSequenceBuilder = new StringBuilder();
|
|
for (int i = 0; i <= Punctuation.getEmojiLevels(); i++) {
|
|
maxEmojiSequenceBuilder.append("1");
|
|
}
|
|
maxEmojiSequence = maxEmojiSequenceBuilder.toString();
|
|
}
|
|
|
|
|
|
@Override
|
|
public boolean onBackspace() {
|
|
if (digitSequence.length() < 1) {
|
|
clearWordStem();
|
|
return false;
|
|
}
|
|
|
|
digitSequence = digitSequence.substring(0, digitSequence.length() - 1);
|
|
if (digitSequence.length() == 0) {
|
|
clearWordStem();
|
|
} else if (stem.length() > digitSequence.length()) {
|
|
stem = stem.substring(0, digitSequence.length() - 1);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
@Override
|
|
public boolean onNumber(Language l, int key, boolean hold, int repeat) {
|
|
if (hold) {
|
|
// hold to type any digit
|
|
reset();
|
|
word = String.valueOf(key);
|
|
} else if (key == 0 && repeat > 0) {
|
|
// repeat "0" to type spaces
|
|
reset();
|
|
word = " ";
|
|
} else {
|
|
// words
|
|
super.reset();
|
|
digitSequence += key;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
@Override
|
|
public void reset() {
|
|
super.reset();
|
|
digitSequence = "";
|
|
stem = "";
|
|
}
|
|
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
@Override
|
|
public boolean shouldAddAutoSpace(InputConnection inputConnection, EditorInfo inputField, boolean isWordAcceptedManually, int incomingKey, boolean hold) {
|
|
return
|
|
settings.getAutoSpace()
|
|
&& !hold
|
|
&& (
|
|
shouldAddAutoSpaceAfterPunctuation(inputField, incomingKey)
|
|
|| shouldAddAutoSpaceAfterWord(inputField, isWordAcceptedManually)
|
|
)
|
|
&& !InputFieldHelper.isThereSpaceAhead(inputConnection);
|
|
}
|
|
|
|
|
|
/**
|
|
* shouldDeletePrecedingSpace
|
|
* When the "auto-space" settings is enabled, determine whether to delete spaces before punctuation.
|
|
* This allows automatic conversion from: "words ." to: "words."
|
|
*/
|
|
@Override
|
|
public boolean shouldDeletePrecedingSpace(EditorInfo inputField) {
|
|
return
|
|
settings.getAutoSpace()
|
|
&& (
|
|
lastAcceptedWord.equals(".")
|
|
|| lastAcceptedWord.equals(",")
|
|
|| lastAcceptedWord.equals(";")
|
|
|| lastAcceptedWord.equals(":")
|
|
|| lastAcceptedWord.equals("!")
|
|
|| lastAcceptedWord.equals("?")
|
|
|| lastAcceptedWord.equals(")")
|
|
|| lastAcceptedWord.equals("]")
|
|
|| lastAcceptedWord.equals("'")
|
|
|| lastAcceptedWord.equals("@")
|
|
)
|
|
&& !InputFieldHelper.isSpecializedTextField(inputField);
|
|
}
|
|
|
|
|
|
/**
|
|
* shouldAddAutoSpaceAfterPunctuation
|
|
* Determines whether to automatically adding a space after certain punctuation signs makes sense.
|
|
* The rules are similar to the ones in the standard Android keyboard (with some exceptions,
|
|
* because we are not using a QWERTY keyboard here).
|
|
*/
|
|
private boolean shouldAddAutoSpaceAfterPunctuation(EditorInfo inputField, int incomingKey) {
|
|
return
|
|
incomingKey != 0
|
|
&& (
|
|
lastAcceptedWord.endsWith(".")
|
|
|| lastAcceptedWord.endsWith(",")
|
|
|| lastAcceptedWord.endsWith(";")
|
|
|| lastAcceptedWord.endsWith(":")
|
|
|| lastAcceptedWord.endsWith("!")
|
|
|| lastAcceptedWord.endsWith("?")
|
|
|| lastAcceptedWord.endsWith(")")
|
|
|| lastAcceptedWord.endsWith("]")
|
|
|| lastAcceptedWord.endsWith("%")
|
|
)
|
|
&& !InputFieldHelper.isSpecializedTextField(inputField);
|
|
}
|
|
|
|
|
|
/**
|
|
* shouldAddAutoSpaceAfterPunctuation
|
|
* Similar to "shouldAddAutoSpaceAfterPunctuation()", but determines whether to add a space after
|
|
* words.
|
|
*/
|
|
private boolean shouldAddAutoSpaceAfterWord(EditorInfo inputField, boolean isWordAcceptedManually) {
|
|
return
|
|
// Do not add space when auto-accepting words, because it feels very confusing when typing.
|
|
isWordAcceptedManually
|
|
// Secondary punctuation
|
|
&& !lastAcceptedSequence.equals("0")
|
|
// Emoji
|
|
&& !lastAcceptedSequence.startsWith("1")
|
|
&& !InputFieldHelper.isSpecializedTextField(inputField);
|
|
}
|
|
|
|
|
|
/**
|
|
* shouldAcceptCurrentSuggestion
|
|
* In this mode, In addition to confirming the suggestion in the input field,
|
|
* we also increase its' priority. This function determines whether we want to do all this or not.
|
|
*/
|
|
@Override
|
|
public boolean shouldAcceptCurrentSuggestion(Language language, int key, boolean hold, boolean repeat) {
|
|
return
|
|
hold
|
|
// Quickly accept suggestions using "space" instead of pressing "ok" then "space"
|
|
|| key == 0
|
|
// Punctuation is considered "a word", so that we can increase the priority as needed
|
|
// Also, it must break the current word.
|
|
|| (!language.isPunctuationPartOfWords() && key == 1 && digitSequence.length() > 0 && !digitSequence.endsWith("1"))
|
|
// On the other hand, letters also "break" punctuation.
|
|
|| (!language.isPunctuationPartOfWords() && key != 1 && digitSequence.endsWith("1"))
|
|
|| (digitSequence.endsWith("0"));
|
|
}
|
|
|
|
|
|
/**
|
|
* clearWordStem
|
|
* Do not filter the suggestions by the word set using "setWordStem()", use only the digit sequence.
|
|
*/
|
|
@Override
|
|
public boolean clearWordStem() {
|
|
stem = "";
|
|
Logger.d("tt9/setWordStem", "Stem filter cleared");
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
/**
|
|
* setWordStem
|
|
* Filter the possible suggestions by the given stem.
|
|
*
|
|
* If exact is "true", the database will be filtered by "stem" and if the stem word is missing,
|
|
* it will be added to the suggestions list.
|
|
* For example: "exac_" -> "exac", {database suggestions...}
|
|
*
|
|
* If "exact" is false, in addition to the above, all possible next combinations will be
|
|
* added to the suggestions list, even if they make no sense.
|
|
* For example: "exac_" -> "exac", "exact", "exacu", "exacv", {database suggestions...}
|
|
*
|
|
*
|
|
* Note that you need to manually get the suggestions again to obtain a filtered list.
|
|
*/
|
|
@Override
|
|
public boolean setWordStem(Language language, String wordStem, boolean exact) {
|
|
if (language == null || wordStem == null || wordStem.length() < 1) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
digitSequence = language.getDigitSequenceForWord(wordStem);
|
|
isStemFuzzy = !exact;
|
|
stem = digitSequence.startsWith("0") || digitSequence.startsWith("1") ? "" : wordStem.toLowerCase(language.getLocale());
|
|
|
|
Logger.d("tt9/setWordStem", "Stem is now: " + stem + (isStemFuzzy ? " (fuzzy)" : ""));
|
|
return true;
|
|
} catch (Exception e) {
|
|
isStemFuzzy = false;
|
|
stem = "";
|
|
|
|
Logger.w("tt9/setWordStem", "Ignoring invalid stem: " + wordStem + ". " + e.getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* getWordStem
|
|
* If "setWordStem()" has accepted a new stem by returning "true", it can be obtained using this.
|
|
*/
|
|
@Override
|
|
public String getWordStem() {
|
|
return stem;
|
|
}
|
|
|
|
|
|
/**
|
|
* isStemFilterFuzzy
|
|
* Returns how strict the stem filter is.
|
|
*/
|
|
@Override
|
|
public boolean isStemFilterFuzzy() {
|
|
return isStemFuzzy;
|
|
}
|
|
|
|
/**
|
|
* loadStaticSuggestions
|
|
* Similar to "loadSuggestions()", but loads suggestions that are not in the database.
|
|
* Returns "false", when there are no static suggestions for the current digitSequence.
|
|
*/
|
|
private boolean loadStaticSuggestions() {
|
|
if (digitSequence.equals("0")) {
|
|
stem = "";
|
|
suggestions = Punctuation.Secondary;
|
|
} else if (containsOnly1Regex.matcher(digitSequence).matches()) {
|
|
stem = "";
|
|
if (digitSequence.length() == 1) {
|
|
suggestions = Punctuation.Main;
|
|
} else {
|
|
digitSequence = digitSequence.length() <= maxEmojiSequence.length() ? digitSequence : maxEmojiSequence;
|
|
suggestions = Punctuation.getEmoji(digitSequence.length() - 2);
|
|
}
|
|
} else {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
/**
|
|
* loadSuggestions
|
|
* Queries the dictionary database for a list of suggestions matching the current language and
|
|
* sequence. Returns "false" when there is nothing to do.
|
|
*
|
|
* "lastWord" is used for generating suggestions when there are no results.
|
|
* See: generatePossibleCompletions()
|
|
*/
|
|
@Override
|
|
public boolean loadSuggestions(Handler handler, Language language, String currentWord) {
|
|
if (loadStaticSuggestions()) {
|
|
super.onSuggestionsUpdated(handler);
|
|
return true;
|
|
}
|
|
|
|
if (digitSequence.length() == 0) {
|
|
suggestions = new ArrayList<>();
|
|
return false;
|
|
}
|
|
|
|
handleSuggestionsExternal = handler;
|
|
currentInputFieldWord = currentWord.toLowerCase(language.getLocale());
|
|
currentLanguage = language;
|
|
super.reset();
|
|
|
|
DictionaryDb.getSuggestions(
|
|
handleSuggestions,
|
|
language,
|
|
digitSequence,
|
|
stem,
|
|
settings.getSuggestionsMin(),
|
|
settings.getSuggestionsMax()
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
/**
|
|
* handleSuggestions
|
|
* Extracts the suggestions from the Message object and passes them to the actual external Handler.
|
|
* If there were no matches in the database, they will be generated based on the "lastInputFieldWord".
|
|
*/
|
|
private final Handler handleSuggestions = new Handler(Looper.getMainLooper()) {
|
|
@Override
|
|
public void handleMessage(Message msg) {
|
|
ArrayList<String> dbSuggestions = msg.getData().getStringArrayList("suggestions");
|
|
dbSuggestions = dbSuggestions == null ? new ArrayList<>() : dbSuggestions;
|
|
|
|
if (dbSuggestions.size() == 0 && digitSequence.length() > 0) {
|
|
emptyDbWarning.emitOnce(currentLanguage);
|
|
dbSuggestions = generatePossibleCompletions(currentLanguage, currentInputFieldWord);
|
|
}
|
|
|
|
suggestions.clear();
|
|
suggestStem();
|
|
suggestions.addAll(generatePossibleStemVariations(currentLanguage, dbSuggestions));
|
|
suggestMoreWords(dbSuggestions);
|
|
|
|
ModePredictive.super.onSuggestionsUpdated(handleSuggestionsExternal);
|
|
}
|
|
};
|
|
|
|
|
|
/**
|
|
* generatePossibleCompletions
|
|
* When there are no matching suggestions after the last key press, generate a list of possible
|
|
* ones, so that the user can complete a missing word that is completely different from the ones
|
|
* in the dictionary.
|
|
*
|
|
* For example, if the word is "missin_" and the last pressed key is "4", the results would be:
|
|
* | missing | missinh | missini |
|
|
*/
|
|
private ArrayList<String> generatePossibleCompletions(Language language, String baseWord) {
|
|
ArrayList<String> generatedWords = new ArrayList<>();
|
|
|
|
// Make sure the displayed word and the digit sequence, we will be generating suggestions from,
|
|
// have the same length, to prevent visual discrepancies.
|
|
baseWord = (baseWord != null && baseWord.length() > 0) ? baseWord.substring(0, Math.min(digitSequence.length() - 1, baseWord.length())) : "";
|
|
|
|
// append all letters for the last digit in the sequence (the last pressed key)
|
|
int lastSequenceDigit = digitSequence.charAt(digitSequence.length() - 1) - '0';
|
|
for (String keyLetter : language.getKeyCharacters(lastSequenceDigit)) {
|
|
// let's skip numbers, because it's weird, for example:
|
|
// | weird | weire | weirf | weir2 |
|
|
if (keyLetter.charAt(0) < '0' || keyLetter.charAt(0) > '9') {
|
|
generatedWords.add(baseWord + keyLetter);
|
|
}
|
|
}
|
|
|
|
// if there are no letters for this key, just append the number
|
|
if (generatedWords.size() == 0) {
|
|
generatedWords.add(baseWord + digitSequence.charAt(digitSequence.length() - 1));
|
|
}
|
|
|
|
return generatedWords;
|
|
}
|
|
|
|
|
|
/**
|
|
* generatePossibleStemVariations
|
|
* Similar to generatePossibleCompletions(), but uses the current filter as a base word. This is
|
|
* used to complement the database results with all possible variations for the next key, when
|
|
* the stem filter is on.
|
|
*
|
|
* It will not generate anything if more than one key was pressed after filtering though.
|
|
*
|
|
* For example, if the filter is "extr", the current word is "extr_" and the user has pressed "1",
|
|
* the database would have returned only "extra", but this function would also
|
|
* generate: "extrb" and "extrc". This is useful for typing an unknown word, that is similar to
|
|
* the ones in the dictionary.
|
|
*/
|
|
private ArrayList<String> generatePossibleStemVariations(Language language, ArrayList<String> dbSuggestions) {
|
|
ArrayList<String> variations = new ArrayList<>();
|
|
if (stem.length() == 0) {
|
|
return variations;
|
|
}
|
|
|
|
if (isStemFuzzy && stem.length() == digitSequence.length() - 1) {
|
|
ArrayList<String> allPossibleVariations = generatePossibleCompletions(language, stem);
|
|
|
|
// first add the known words, because it makes more sense to see them first
|
|
for (String word : allPossibleVariations) {
|
|
if (dbSuggestions.contains(word)) {
|
|
variations.add(word);
|
|
}
|
|
}
|
|
|
|
// then add the unknown ones, so they can be used as possible beginnings of new words.
|
|
for (String word : allPossibleVariations) {
|
|
if (!dbSuggestions.contains(word)) {
|
|
variations.add(word);
|
|
}
|
|
}
|
|
}
|
|
|
|
return variations;
|
|
}
|
|
|
|
/**
|
|
* suggestStem
|
|
* Add the current stem filter to the suggestion list, when it has length of X and
|
|
* the user has pressed X keys.
|
|
*/
|
|
private void suggestStem() {
|
|
if (stem.length() > 0 && stem.length() == digitSequence.length()) {
|
|
suggestions.add(stem);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* suggestMoreWords
|
|
* Takes a list of words and appends them to the suggestion list, if they are missing.
|
|
*/
|
|
private void suggestMoreWords(ArrayList<String> newSuggestions) {
|
|
for (String word : newSuggestions) {
|
|
if (!suggestions.contains(word)) {
|
|
suggestions.add(word);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* onAcceptSuggestion
|
|
* Bring this word up in the suggestions list next time.
|
|
*/
|
|
@Override
|
|
public void onAcceptSuggestion(Language language, String currentWord) {
|
|
lastAcceptedWord = currentWord;
|
|
lastAcceptedSequence = digitSequence;
|
|
reset();
|
|
|
|
if (currentWord.length() == 0) {
|
|
Logger.i("acceptCurrentSuggestion", "Current word is empty. Nothing to accept.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
String sequence = language.getDigitSequenceForWord(currentWord);
|
|
|
|
// emoji and punctuation are not in the database, so there is no point in
|
|
// running queries that would update nothing
|
|
if (!sequence.startsWith("11") && !sequence.equals("1") && !sequence.equals("0")) {
|
|
DictionaryDb.incrementWordFrequency(language, currentWord, sequence);
|
|
}
|
|
} catch (Exception e) {
|
|
Logger.e("tt9/ModePredictive", "Failed incrementing priority of word: '" + currentWord + "'. " + e.getMessage());
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* adjustSuggestionTextCase
|
|
* In addition to uppercase/lowercase, here we use the result from determineNextWordTextCase(),
|
|
* to conveniently start sentences with capitals or whatnot.
|
|
*
|
|
* Also, by default we preserve any mixed case words in the dictionary,
|
|
* for example: "dB", "Mb", proper names, German nouns, that always start with a capital,
|
|
* or Dutch words such as: "'s-Hertogenbosch".
|
|
*/
|
|
@Override
|
|
protected String adjustSuggestionTextCase(String word, int newTextCase, Language language) {
|
|
switch (newTextCase) {
|
|
case CASE_UPPER:
|
|
return word.toUpperCase(language.getLocale());
|
|
case CASE_LOWER:
|
|
return word.toLowerCase(language.getLocale());
|
|
case CASE_CAPITALIZE:
|
|
return language.isMixedCaseWord(word) ? word : language.capitalize(word);
|
|
case CASE_DICTIONARY:
|
|
return language.isMixedCaseWord(word) ? word : word.toLowerCase(language.getLocale());
|
|
default:
|
|
return word;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* determineNextWordTextCase
|
|
* Dynamically determine text case of words as the user types, to reduce key presses.
|
|
* For example, this function will return CASE_LOWER by default, but CASE_UPPER at the beginning
|
|
* of a sentence.
|
|
*/
|
|
@Override
|
|
public void determineNextWordTextCase(SettingsStore settings, boolean isThereText, String textBeforeCursor) {
|
|
if (!settings.getAutoTextCase()) {
|
|
return;
|
|
}
|
|
|
|
// If the user wants to type in uppercase, this must be for a reason, so we better not override it.
|
|
if (textCase == CASE_UPPER) {
|
|
return;
|
|
}
|
|
|
|
if (textFieldTextCase != CASE_UNDEFINED) {
|
|
textCase = textFieldTextCase;
|
|
return;
|
|
}
|
|
|
|
// start of text
|
|
if (!isThereText) {
|
|
textCase = CASE_CAPITALIZE;
|
|
return;
|
|
}
|
|
|
|
// start of sentence, excluding after "..."
|
|
if (endOfSentenceRegex.matcher(textBeforeCursor).find()) {
|
|
textCase = CASE_CAPITALIZE;
|
|
return;
|
|
}
|
|
|
|
textCase = CASE_DICTIONARY;
|
|
}
|
|
|
|
|
|
@Override
|
|
public void nextTextCase() {
|
|
textFieldTextCase = CASE_UNDEFINED; // since it's a user's choice, the default matters no more
|
|
super.nextTextCase();
|
|
}
|
|
|
|
@Override final public boolean isPredictive() { return true; }
|
|
@Override public int getSequenceLength() { return digitSequence.length(); }
|
|
@Override public boolean shouldTrackUpDown() { return true; }
|
|
@Override public boolean shouldTrackLeftRight() { return true; }
|
|
}
|