diff --git a/src/io/github/sspanak/tt9/ime/TraditionalT9.java b/src/io/github/sspanak/tt9/ime/TraditionalT9.java index 8f5f9f82..e93a4712 100644 --- a/src/io/github/sspanak/tt9/ime/TraditionalT9.java +++ b/src/io/github/sspanak/tt9/ime/TraditionalT9.java @@ -51,6 +51,8 @@ public class TraditionalT9 extends KeyPadHandler { private void loadSettings() { mLanguage = LanguageCollection.getLanguage(settings.getInputLanguage()); mEnabledLanguages = settings.getEnabledLanguageIds(); + validateLanguages(); + mInputMode = InputMode.getInstance(settings, mLanguage, settings.getInputMode()); mInputMode.setTextCase(settings.getTextCase()); } diff --git a/src/io/github/sspanak/tt9/ime/helpers/TextField.java b/src/io/github/sspanak/tt9/ime/helpers/TextField.java index cad7d9b1..3913daac 100644 --- a/src/io/github/sspanak/tt9/ime/helpers/TextField.java +++ b/src/io/github/sspanak/tt9/ime/helpers/TextField.java @@ -43,12 +43,22 @@ public class TextField { /** - * isThereSpaceAhead - * Checks whether there is a space after the cursor. + * getPreviousChar + * Gets the character before the cursor. */ - public boolean isThereSpaceAhead() { - CharSequence after = connection != null ? connection.getTextAfterCursor(1, 0) : null; - return after != null && after.equals(" "); + public String getPreviousChars(int numberOfChars) { + CharSequence character = connection != null ? connection.getTextBeforeCursor(numberOfChars, 0) : null; + return character != null ? character.toString() : ""; + } + + + /** + * getNextChar + * Gets the character after the cursor. + */ + public String getNextChars(int numberOfChars) { + CharSequence character = connection != null ? connection.getTextAfterCursor(numberOfChars, 0) : null; + return character != null ? character.toString() : ""; } diff --git a/src/io/github/sspanak/tt9/ime/modes/InputMode.java b/src/io/github/sspanak/tt9/ime/modes/InputMode.java index 9e4cc604..3953ba15 100644 --- a/src/io/github/sspanak/tt9/ime/modes/InputMode.java +++ b/src/io/github/sspanak/tt9/ime/modes/InputMode.java @@ -49,7 +49,7 @@ abstract public class InputMode { public boolean onBackspace() { return false; } abstract public boolean onNumber(int key, boolean hold, int repeat); - // Suggestions + // Predictions public void onAcceptSuggestion(String suggestion) {} protected void onSuggestionsUpdated(Handler handler) { handler.sendEmptyMessage(0); } public boolean loadSuggestions(Handler handler, String currentWord) { return false; } diff --git a/src/io/github/sspanak/tt9/ime/modes/ModePredictive.java b/src/io/github/sspanak/tt9/ime/modes/ModePredictive.java index 36b01bc9..1dacf52c 100644 --- a/src/io/github/sspanak/tt9/ime/modes/ModePredictive.java +++ b/src/io/github/sspanak/tt9/ime/modes/ModePredictive.java @@ -4,21 +4,18 @@ import android.os.Handler; import android.os.Looper; import android.os.Message; -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.helpers.InputType; import io.github.sspanak.tt9.ime.helpers.TextField; +import io.github.sspanak.tt9.ime.modes.helpers.AutoSpace; +import io.github.sspanak.tt9.ime.modes.helpers.AutoTextCase; +import io.github.sspanak.tt9.ime.modes.helpers.Predictions; import io.github.sspanak.tt9.languages.InvalidLanguageCharactersException; import io.github.sspanak.tt9.languages.Language; -import io.github.sspanak.tt9.languages.Characters; 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; } @@ -32,30 +29,22 @@ public class ModePredictive extends InputMode { private String stem = ""; // async suggestion handling - private String currentInputFieldWord = ""; private static Handler handleSuggestionsExternal; - // auto text case selection - private final Pattern startOfSentenceRegex = Pattern.compile("(?(); - return false; - } + predictions + .setDigitSequence(digitSequence) + .setIsStemFuzzy(isStemFuzzy) + .setStem(stem) + .setLanguage(language) + .setInputWord(currentWord) + .setWordsChangedHandler(handleSuggestions); handleSuggestionsExternal = handler; - currentInputFieldWord = currentWord.toLowerCase(language.getLocale()); super.reset(); - DictionaryDb.getSuggestions( - handleSuggestions, - language, - digitSequence, - stem, - settings.getSuggestionsMin(), - settings.getSuggestionsMax() - ); - - return true; + return predictions.load(); } @@ -276,125 +228,15 @@ public class ModePredictive extends InputMode { */ private final Handler handleSuggestions = new Handler(Looper.getMainLooper()) { @Override - public void handleMessage(Message msg) { - ArrayList dbSuggestions = msg.getData().getStringArrayList("suggestions"); - dbSuggestions = dbSuggestions == null ? new ArrayList<>() : dbSuggestions; - - if (dbSuggestions.size() == 0 && digitSequence.length() > 0) { - emptyDbWarning.emitOnce(language); - dbSuggestions = generatePossibleCompletions(currentInputFieldWord); - } - + public void handleMessage(Message m) { suggestions.clear(); - suggestStem(); - suggestions.addAll(generatePossibleStemVariations(dbSuggestions)); - suggestMoreWords(dbSuggestions); + suggestions.addAll(predictions.getList()); - ModePredictive.super.onSuggestionsUpdated(handleSuggestionsExternal); + 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 generatePossibleCompletions(String baseWord) { - ArrayList 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 generatePossibleStemVariations(ArrayList dbSuggestions) { - ArrayList variations = new ArrayList<>(); - if (stem.length() == 0) { - return variations; - } - - if (isStemFuzzy && stem.length() == digitSequence.length() - 1) { - ArrayList allPossibleVariations = generatePossibleCompletions(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 newSuggestions) { - for (String word : newSuggestions) { - if (!suggestions.contains(word)) { - suggestions.add(word); - } - } - } - - /** * onAcceptSuggestion * Bring this word up in the suggestions list next time. @@ -424,70 +266,16 @@ public class ModePredictive extends InputMode { } - /** - * 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) { - 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; - } + return autoTextCase.adjustSuggestionTextCase(language, word, newTextCase); } - - /** - * 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 (startOfSentenceRegex.matcher(textBeforeCursor).find()) { - textCase = CASE_CAPITALIZE; - return; - } - - textCase = CASE_DICTIONARY; + textCase = autoTextCase.determineNextWordTextCase(isThereText, textCase, textFieldTextCase, textBeforeCursor); } - @Override public void nextTextCase() { textFieldTextCase = CASE_UNDEFINED; // since it's a user's choice, the default matters no more @@ -515,90 +303,26 @@ public class ModePredictive extends InputMode { } - /** - * 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(InputType inputType, TextField textField, boolean isWordAcceptedManually, int incomingKey, boolean hold, boolean repeat) { - return - settings.getAutoSpace() - && !hold - && ( - shouldAddAutoSpaceAfterPunctuation(inputType, incomingKey, repeat) - || shouldAddAutoSpaceAfterWord(inputType, isWordAcceptedManually) - ) - && !textField.isThereSpaceAhead(); + return autoSpace + .setLastWord(lastAcceptedWord) + .setLastSequence(lastAcceptedSequence) + .setInputType(inputType) + .setTextField(textField) + .shouldAddAutoSpace(isWordAcceptedManually, incomingKey, hold, repeat); + } - /** - * 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(InputType inputType, int incomingKey, boolean repeat) { - return - (incomingKey != 0 || repeat) - && ( - lastAcceptedWord.endsWith(".") - || lastAcceptedWord.endsWith(",") - || lastAcceptedWord.endsWith(";") - || lastAcceptedWord.endsWith(":") - || lastAcceptedWord.endsWith("!") - || lastAcceptedWord.endsWith("?") - || lastAcceptedWord.endsWith(")") - || lastAcceptedWord.endsWith("]") - || lastAcceptedWord.endsWith("%") - ) - && !inputType.isSpecialized(); - } - - - /** - * shouldAddAutoSpaceAfterPunctuation - * Similar to "shouldAddAutoSpaceAfterPunctuation()", but determines whether to add a space after - * words. - */ - private boolean shouldAddAutoSpaceAfterWord(InputType inputType, 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") - && !inputType.isSpecialized(); - } - - - /** - * 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(InputType inputType) { - return - settings.getAutoSpace() - && ( - lastAcceptedWord.equals(".") - || lastAcceptedWord.equals(",") - || lastAcceptedWord.equals(";") - || lastAcceptedWord.equals(":") - || lastAcceptedWord.equals("!") - || lastAcceptedWord.equals("?") - || lastAcceptedWord.equals(")") - || lastAcceptedWord.equals("]") - || lastAcceptedWord.equals("'") - || lastAcceptedWord.equals("@") - ) - && !inputType.isSpecialized(); + return autoSpace + .setLastWord(lastAcceptedWord) + .setLastSequence(lastAcceptedSequence) + .setInputType(inputType) + .setTextField(null) + .shouldDeletePrecedingSpace(); } diff --git a/src/io/github/sspanak/tt9/ime/modes/helpers/AutoSpace.java b/src/io/github/sspanak/tt9/ime/modes/helpers/AutoSpace.java new file mode 100644 index 00000000..9311a71f --- /dev/null +++ b/src/io/github/sspanak/tt9/ime/modes/helpers/AutoSpace.java @@ -0,0 +1,133 @@ +package io.github.sspanak.tt9.ime.modes.helpers; + +import java.util.regex.Pattern; + +import io.github.sspanak.tt9.Logger; +import io.github.sspanak.tt9.ime.helpers.InputType; +import io.github.sspanak.tt9.ime.helpers.TextField; +import io.github.sspanak.tt9.preferences.SettingsStore; + +public class AutoSpace { + private final Pattern nextIsPunctuation = Pattern.compile("\\p{Punct}"); + private final SettingsStore settings; + + private InputType inputType; + private TextField textField; + private String lastWord; + private String lastSequence; + + public AutoSpace(SettingsStore settingsStore) { + settings = settingsStore; + } + + public AutoSpace setInputType(InputType inputType) { + this.inputType = inputType; + return this; + } + + public AutoSpace setTextField(TextField textField) { + this.textField = textField; + return this; + } + + public AutoSpace setLastWord(String lastWord) { + this.lastWord = lastWord; + return this; + } + + public AutoSpace setLastSequence(String lastSequence) { + this.lastSequence = lastSequence; + return this; + } + + /** + * 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. + */ + public boolean shouldAddAutoSpace(boolean isWordAcceptedManually, int incomingKey, boolean hold, boolean repeat) { + String previousChars = textField.getPreviousChars(2); + String nextChars = textField.getNextChars(2); + Logger.d("shouldAddAutoSpace", "next chars: '" + nextChars + "'"); + + return + settings.getAutoSpace() + && !hold + && ( + shouldAddAutoSpaceAfterPunctuation(previousChars, incomingKey, repeat) + || shouldAddAutoSpaceAfterWord(isWordAcceptedManually) + ) + && !nextChars.startsWith(" ") + && !nextIsPunctuation.matcher(nextChars).find(); + } + + + /** + * 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(String previousChars, int incomingKey, boolean repeat) { + return + (incomingKey != 0 || repeat) + && !inputType.isSpecialized() + && ( + previousChars.endsWith(".") + || previousChars.endsWith(",") + || previousChars.endsWith(";") + || previousChars.endsWith(":") + || previousChars.endsWith("!") + || previousChars.endsWith("?") + || previousChars.endsWith(")") + || previousChars.endsWith("]") + || previousChars.endsWith("%") + || previousChars.endsWith(" -") + || previousChars.endsWith(" /") + ); + } + + + /** + * shouldAddAutoSpaceAfterPunctuation + * Similar to "shouldAddAutoSpaceAfterPunctuation()", but determines whether to add a space after + * words. + */ + private boolean shouldAddAutoSpaceAfterWord(boolean isWordAcceptedManually) { + return + // Do not add space when auto-accepting words, because it feels very confusing when typing. + isWordAcceptedManually + // Secondary punctuation + && !lastSequence.equals("0") + // Emoji + && !lastSequence.startsWith("1") + && !inputType.isSpecialized(); + } + + + /** + * shouldDeletePrecedingSpace + * When the "auto-space" settings is enabled, determine whether to delete spaces before punctuation. + * This allows automatic conversion from: "words ." to: "words." + */ + public boolean shouldDeletePrecedingSpace() { + return + settings.getAutoSpace() + && ( + lastWord.equals(".") + || lastWord.equals(",") + || lastWord.equals(";") + || lastWord.equals(":") + || lastWord.equals("!") + || lastWord.equals("?") + || lastWord.equals(")") + || lastWord.equals("]") + || lastWord.equals("'") + || lastWord.equals("@") + ) + && !inputType.isSpecialized(); + } +} diff --git a/src/io/github/sspanak/tt9/ime/modes/helpers/AutoTextCase.java b/src/io/github/sspanak/tt9/ime/modes/helpers/AutoTextCase.java new file mode 100644 index 00000000..fa473df4 --- /dev/null +++ b/src/io/github/sspanak/tt9/ime/modes/helpers/AutoTextCase.java @@ -0,0 +1,76 @@ +package io.github.sspanak.tt9.ime.modes.helpers; + +import java.util.regex.Pattern; + +import io.github.sspanak.tt9.ime.modes.InputMode; +import io.github.sspanak.tt9.languages.Language; +import io.github.sspanak.tt9.preferences.SettingsStore; + +public class AutoTextCase { + private final Pattern startOfSentenceRegex = Pattern.compile("(? words = new ArrayList<>(); + + // punctuation/emoji + private final Pattern containsOnly1Regex = Pattern.compile("^1+$"); + private final String maxEmojiSequence; + + + public Predictions(SettingsStore settingsStore) { + emptyDbWarning = new EmptyDatabaseWarning(settingsStore); + settings = settingsStore; + + // 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 <= Characters.getEmojiLevels(); i++) { + maxEmojiSequenceBuilder.append("1"); + } + maxEmojiSequence = maxEmojiSequenceBuilder.toString(); + } + + + public Predictions setLanguage(Language language) { + this.language = language; + return this; + } + + public Predictions setDigitSequence(String digitSequence) { + this.digitSequence = digitSequence; + return this; + } + + public Predictions setIsStemFuzzy(boolean yes) { + this.isStemFuzzy = yes; + return this; + } + + public Predictions setStem(String stem) { + this.stem = stem; + return this; + } + + public Predictions setInputWord(String inputWord) { + this.inputWord = inputWord.toLowerCase(language.getLocale()); + return this; + } + + public Predictions setWordsChangedHandler(Handler handler) { + wordsChangedHandler = handler; + return this; + } + + public ArrayList getList() { + return words; + } + + + /** + * suggestStem + * Add the current stem filter to the predictions list, when it has length of X and + * the user has pressed X keys (otherwise, it makes no sense to add it). + */ + private void suggestStem() { + if (stem.length() > 0 && stem.length() == digitSequence.length()) { + words.add(stem); + } + } + + + /** + * suggestMissingWords + * Takes a list of words and appends them to the words list, if they are missing. + */ + private void suggestMissingWords(ArrayList newWords) { + for (String newWord : newWords) { + if (!words.contains(newWord) && !words.contains(newWord.toLowerCase(language.getLocale()))) { + words.add(newWord); + } + } + } + + + /** + * onWordsChanged + * Notify the external handler the word list has changed, so they can get the new ones using getList(). + */ + private void onWordsChanged() { + wordsChangedHandler.sendEmptyMessage(0); + } + + + + /** + * load + * Queries the dictionary database for a list of words matching the current language and + * sequence or loads the static ones. + * + * Returns "false" on invalid digitSequence. + */ + public boolean load() { + if (digitSequence == null || digitSequence.length() == 0) { + words = new ArrayList<>(); + onWordsChanged(); + return false; + } + + if (loadStatic()) { + onWordsChanged(); + } else { + DictionaryDb.getSuggestions( + dbWordsHandler, + language, + digitSequence, + stem, + settings.getSuggestionsMin(), + settings.getSuggestionsMax() + ); + } + + return true; + } + + + /** + * loadStatic + * Similar to "load()", but loads words that are not in the database. + * Returns "false", when there are no static options for the current digitSequence. + */ + private boolean loadStatic() { + if (digitSequence.equals("0")) { + stem = ""; + words = language.getKeyCharacters(0, false); + } else if (containsOnly1Regex.matcher(digitSequence).matches()) { + stem = ""; + if (digitSequence.length() == 1) { + words = language.getKeyCharacters(1, false); + } else { + digitSequence = digitSequence.length() <= maxEmojiSequence.length() ? digitSequence : maxEmojiSequence; + words = Characters.getEmoji(digitSequence.length() - 2); + } + } else { + return false; + } + + return true; + } + + + /** + * dbWordsHandler + * Extracts the words from the Message object, generates extra words, if necessary, then + * notifies the external handler it is now possible to use "getList()". + * If there were no matches in the database, they will be generated based on the "inputWord". + */ + private final Handler dbWordsHandler = new Handler(Looper.getMainLooper()) { + @Override + public void handleMessage(Message msg) { + ArrayList dbWords = msg.getData().getStringArrayList("suggestions"); + dbWords = dbWords != null ? dbWords : new ArrayList<>(); + + if (dbWords.size() == 0 && digitSequence.length() > 0) { + emptyDbWarning.emitOnce(language); + dbWords = generatePossibleCompletions(inputWord); + } + + words.clear(); + suggestStem(); + suggestMissingWords(generatePossibleStemVariations(dbWords)); + suggestMissingWords(dbWords); + + onWordsChanged(); + } + }; + + + /** + * 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 generatePossibleCompletions(String baseWord) { + ArrayList 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 generatePossibleStemVariations(ArrayList dbWords) { + ArrayList variations = new ArrayList<>(); + if (stem.length() == 0) { + return variations; + } + + if (isStemFuzzy && stem.length() == digitSequence.length() - 1) { + ArrayList allPossibleVariations = generatePossibleCompletions(stem); + + // first add the known words, because it makes more sense to see them first + for (String variation : allPossibleVariations) { + if (dbWords.contains(variation)) { + variations.add(variation); + } + } + + // then add the unknown ones, so they can be used as possible beginnings of new words. + for (String word : allPossibleVariations) { + if (!dbWords.contains(word)) { + variations.add(word); + } + } + } + + return variations; + } +}