1
0
Fork 0

Punctuation Improvements (#211)

improved auto space adjustment around '-' and '/'

improved auto space adjustment when the next character is a punctuation mark

code cleanup: moved auto text case code, auto space code and suggestion loading code to their own classes
This commit is contained in:
Dimo Karaivanov 2023-03-13 15:33:54 +02:00 committed by GitHub
parent ca15ff230b
commit a41dd9edd5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 546 additions and 323 deletions

View file

@ -51,6 +51,8 @@ public class TraditionalT9 extends KeyPadHandler {
private void loadSettings() { private void loadSettings() {
mLanguage = LanguageCollection.getLanguage(settings.getInputLanguage()); mLanguage = LanguageCollection.getLanguage(settings.getInputLanguage());
mEnabledLanguages = settings.getEnabledLanguageIds(); mEnabledLanguages = settings.getEnabledLanguageIds();
validateLanguages();
mInputMode = InputMode.getInstance(settings, mLanguage, settings.getInputMode()); mInputMode = InputMode.getInstance(settings, mLanguage, settings.getInputMode());
mInputMode.setTextCase(settings.getTextCase()); mInputMode.setTextCase(settings.getTextCase());
} }

View file

@ -43,12 +43,22 @@ public class TextField {
/** /**
* isThereSpaceAhead * getPreviousChar
* Checks whether there is a space after the cursor. * Gets the character before the cursor.
*/ */
public boolean isThereSpaceAhead() { public String getPreviousChars(int numberOfChars) {
CharSequence after = connection != null ? connection.getTextAfterCursor(1, 0) : null; CharSequence character = connection != null ? connection.getTextBeforeCursor(numberOfChars, 0) : null;
return after != null && after.equals(" "); 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() : "";
} }

View file

@ -49,7 +49,7 @@ abstract public class InputMode {
public boolean onBackspace() { return false; } public boolean onBackspace() { return false; }
abstract public boolean onNumber(int key, boolean hold, int repeat); abstract public boolean onNumber(int key, boolean hold, int repeat);
// Suggestions // Predictions
public void onAcceptSuggestion(String suggestion) {} public void onAcceptSuggestion(String suggestion) {}
protected void onSuggestionsUpdated(Handler handler) { handler.sendEmptyMessage(0); } protected void onSuggestionsUpdated(Handler handler) { handler.sendEmptyMessage(0); }
public boolean loadSuggestions(Handler handler, String currentWord) { return false; } public boolean loadSuggestions(Handler handler, String currentWord) { return false; }

View file

@ -4,21 +4,18 @@ import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.os.Message; import android.os.Message;
import java.util.ArrayList;
import java.util.regex.Pattern;
import io.github.sspanak.tt9.Logger; import io.github.sspanak.tt9.Logger;
import io.github.sspanak.tt9.db.DictionaryDb; 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.InputType;
import io.github.sspanak.tt9.ime.helpers.TextField; 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.InvalidLanguageCharactersException;
import io.github.sspanak.tt9.languages.Language; import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.languages.Characters;
import io.github.sspanak.tt9.preferences.SettingsStore; import io.github.sspanak.tt9.preferences.SettingsStore;
public class ModePredictive extends InputMode { public class ModePredictive extends InputMode {
private final EmptyDatabaseWarning emptyDbWarning;
private final SettingsStore settings; private final SettingsStore settings;
public int getId() { return MODE_PREDICTIVE; } public int getId() { return MODE_PREDICTIVE; }
@ -32,30 +29,22 @@ public class ModePredictive extends InputMode {
private String stem = ""; private String stem = "";
// async suggestion handling // async suggestion handling
private String currentInputFieldWord = "";
private static Handler handleSuggestionsExternal; private static Handler handleSuggestionsExternal;
// auto text case selection // text analysis tools
private final Pattern startOfSentenceRegex = Pattern.compile("(?<!\\.)(^|[.?!¿¡])\\s*$"); private final AutoSpace autoSpace;
private final AutoTextCase autoTextCase;
// punctuation/emoji private final Predictions predictions;
private final Pattern containsOnly1Regex = Pattern.compile("^1+$");
private final String maxEmojiSequence;
ModePredictive(SettingsStore settings, Language lang) { ModePredictive(SettingsStore settings, Language lang) {
changeLanguage(lang); changeLanguage(lang);
emptyDbWarning = new EmptyDatabaseWarning(settings); autoSpace = new AutoSpace(settings);
this.settings = settings; autoTextCase = new AutoTextCase(settings);
predictions = new Predictions(settings);
// digitSequence limiter when selecting emoji this.settings = settings;
// "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();
} }
@ -207,65 +196,28 @@ public class ModePredictive extends InputMode {
} }
/**
* 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 = language.getKeyCharacters(0, false);
} else if (containsOnly1Regex.matcher(digitSequence).matches()) {
stem = "";
if (digitSequence.length() == 1) {
suggestions = language.getKeyCharacters(1, false);
} else {
digitSequence = digitSequence.length() <= maxEmojiSequence.length() ? digitSequence : maxEmojiSequence;
suggestions = Characters.getEmoji(digitSequence.length() - 2);
}
} else {
return false;
}
return true;
}
/** /**
* loadSuggestions * loadSuggestions
* Queries the dictionary database for a list of suggestions matching the current language and * Loads the possible list of suggestions for the current digitSequence.
* sequence. Returns "false" when there is nothing to do. * Returns "false" on invalid sequence.
* *
* "lastWord" is used for generating suggestions when there are no results. * "currentWord" is used for generating suggestions when there are no results.
* See: generatePossibleCompletions() * See: Predictions.generatePossibleCompletions()
*/ */
@Override @Override
public boolean loadSuggestions(Handler handler, String currentWord) { public boolean loadSuggestions(Handler handler, String currentWord) {
if (loadStaticSuggestions()) { predictions
super.onSuggestionsUpdated(handler); .setDigitSequence(digitSequence)
return true; .setIsStemFuzzy(isStemFuzzy)
} .setStem(stem)
.setLanguage(language)
if (digitSequence.length() == 0) { .setInputWord(currentWord)
suggestions = new ArrayList<>(); .setWordsChangedHandler(handleSuggestions);
return false;
}
handleSuggestionsExternal = handler; handleSuggestionsExternal = handler;
currentInputFieldWord = currentWord.toLowerCase(language.getLocale());
super.reset(); super.reset();
DictionaryDb.getSuggestions( return predictions.load();
handleSuggestions,
language,
digitSequence,
stem,
settings.getSuggestionsMin(),
settings.getSuggestionsMax()
);
return true;
} }
@ -276,125 +228,15 @@ public class ModePredictive extends InputMode {
*/ */
private final Handler handleSuggestions = new Handler(Looper.getMainLooper()) { private final Handler handleSuggestions = new Handler(Looper.getMainLooper()) {
@Override @Override
public void handleMessage(Message msg) { public void handleMessage(Message m) {
ArrayList<String> dbSuggestions = msg.getData().getStringArrayList("suggestions");
dbSuggestions = dbSuggestions == null ? new ArrayList<>() : dbSuggestions;
if (dbSuggestions.size() == 0 && digitSequence.length() > 0) {
emptyDbWarning.emitOnce(language);
dbSuggestions = generatePossibleCompletions(currentInputFieldWord);
}
suggestions.clear(); suggestions.clear();
suggestStem(); suggestions.addAll(predictions.getList());
suggestions.addAll(generatePossibleStemVariations(dbSuggestions));
suggestMoreWords(dbSuggestions);
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<String> generatePossibleCompletions(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(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(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 * onAcceptSuggestion
* Bring this word up in the suggestions list next time. * 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 @Override
protected String adjustSuggestionTextCase(String word, int newTextCase) { protected String adjustSuggestionTextCase(String word, int newTextCase) {
switch (newTextCase) { return autoTextCase.adjustSuggestionTextCase(language, word, 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 @Override
public void determineNextWordTextCase(SettingsStore settings, boolean isThereText, String textBeforeCursor) { public void determineNextWordTextCase(SettingsStore settings, boolean isThereText, String textBeforeCursor) {
if (!settings.getAutoTextCase()) { textCase = autoTextCase.determineNextWordTextCase(isThereText, textCase, textFieldTextCase, textBeforeCursor);
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;
} }
@Override @Override
public void nextTextCase() { public void nextTextCase() {
textFieldTextCase = CASE_UNDEFINED; // since it's a user's choice, the default matters no more 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 @Override
public boolean shouldAddAutoSpace(InputType inputType, TextField textField, boolean isWordAcceptedManually, int incomingKey, boolean hold, boolean repeat) { public boolean shouldAddAutoSpace(InputType inputType, TextField textField, boolean isWordAcceptedManually, int incomingKey, boolean hold, boolean repeat) {
return return autoSpace
settings.getAutoSpace() .setLastWord(lastAcceptedWord)
&& !hold .setLastSequence(lastAcceptedSequence)
&& ( .setInputType(inputType)
shouldAddAutoSpaceAfterPunctuation(inputType, incomingKey, repeat) .setTextField(textField)
|| shouldAddAutoSpaceAfterWord(inputType, isWordAcceptedManually) .shouldAddAutoSpace(isWordAcceptedManually, incomingKey, hold, repeat);
)
&& !textField.isThereSpaceAhead();
} }
/**
* 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 @Override
public boolean shouldDeletePrecedingSpace(InputType inputType) { public boolean shouldDeletePrecedingSpace(InputType inputType) {
return return autoSpace
settings.getAutoSpace() .setLastWord(lastAcceptedWord)
&& ( .setLastSequence(lastAcceptedSequence)
lastAcceptedWord.equals(".") .setInputType(inputType)
|| lastAcceptedWord.equals(",") .setTextField(null)
|| lastAcceptedWord.equals(";") .shouldDeletePrecedingSpace();
|| lastAcceptedWord.equals(":")
|| lastAcceptedWord.equals("!")
|| lastAcceptedWord.equals("?")
|| lastAcceptedWord.equals(")")
|| lastAcceptedWord.equals("]")
|| lastAcceptedWord.equals("'")
|| lastAcceptedWord.equals("@")
)
&& !inputType.isSpecialized();
} }

View file

@ -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();
}
}

View file

@ -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("(?<!\\.)(^|[.?!¿¡])\\s*$");
private final SettingsStore settings;
public AutoTextCase(SettingsStore settingsStore) {
settings = settingsStore;
}
/**
* 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".
*/
public String adjustSuggestionTextCase(Language language, String word, int newTextCase) {
switch (newTextCase) {
case InputMode.CASE_UPPER:
return word.toUpperCase(language.getLocale());
case InputMode.CASE_LOWER:
return word.toLowerCase(language.getLocale());
case InputMode.CASE_CAPITALIZE:
return language.isMixedCaseWord(word) ? word : language.capitalize(word);
case InputMode.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.
*/
public int determineNextWordTextCase(boolean isThereText, int currentTextCase, int textFieldTextCase, String textBeforeCursor) {
if (
// When the setting is off, don't do any changes.
!settings.getAutoTextCase()
// If the user wants to type in uppercase, this must be for a reason, so we better not override it.
|| currentTextCase == InputMode.CASE_UPPER
) {
return currentTextCase;
}
if (textFieldTextCase != InputMode.CASE_UNDEFINED) {
return textFieldTextCase;
}
// start of text
if (!isThereText) {
return InputMode.CASE_CAPITALIZE;
}
// start of sentence, excluding after "..."
if (startOfSentenceRegex.matcher(textBeforeCursor).find()) {
return InputMode.CASE_CAPITALIZE;
}
return InputMode.CASE_DICTIONARY;
}
}

View file

@ -0,0 +1,278 @@
package io.github.sspanak.tt9.ime.modes.helpers;
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.db.DictionaryDb;
import io.github.sspanak.tt9.ime.EmptyDatabaseWarning;
import io.github.sspanak.tt9.languages.Characters;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.preferences.SettingsStore;
public class Predictions {
private final EmptyDatabaseWarning emptyDbWarning;
private final SettingsStore settings;
private Language language;
private String digitSequence;
private boolean isStemFuzzy;
private String stem;
private String inputWord;
// async operations
private Handler wordsChangedHandler;
// data
private ArrayList<String> 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<String> 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<String> 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<String> 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<String> generatePossibleCompletions(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(ArrayList<String> dbWords) {
ArrayList<String> variations = new ArrayList<>();
if (stem.length() == 0) {
return variations;
}
if (isStemFuzzy && stem.length() == digitSequence.length() - 1) {
ArrayList<String> 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;
}
}