diff --git a/README.md b/README.md index d46ccde6..f551236c 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ TT9 is an IME (Input Method Editor) for Android devices with hardware keypad. It This is an updated version of the [original project](https://github.com/Clam-/TraditionalT9) by Clam-. +## Using Traditional T9 +If you just wish to install and use TT9, see the [user manual](docs/user-manual.md). You don't need to read anything below this line. + ## Building The recommended way of building is using Android Studio. As the of time of writing this, the current version is: Android Studio Dolphin | 2021.3.1. @@ -53,9 +56,6 @@ To support a new language one needs to: - Add new entries in `res/values/arrays.xml`. - Add translations in `res/values/strings-your-lang`. The Android Studio translation editor is very handy. -## Using the app -See the [user manual](docs/user-manual.md). - ## Word Lists Here is detailed information and licenses about the word lists used: - [Bulgarian word list](docs/bgWordlistReadme.txt) diff --git a/docs/user-manual.md b/docs/user-manual.md index 453ec590..290655f2 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -14,7 +14,7 @@ _The actual menu names may vary depending on your Android version and phone._ ### Enabling Predictive Mode With the default settings, it is only possible to type in 123 and ABC modes. In order to enable the Predictive mode, there are additional steps: -- Open the [Configuration screen](#configuration-options). +- Open the [Settings screen](#settings-screen). - Select the desired languages. - Load the dictionaries. @@ -23,19 +23,30 @@ _If you don't do the above, there will be no suggestions when typing in Predicti ## Hotkeys #### D-pad Up (↑): -Select previous word suggestion. +Select previous word/letter suggestion. #### D-pad Down (↓): -Select next word suggestion. +Select next word/letter suggestion. + +#### D-pad Right (→): +_Predictive mode only._ + +Filter the suggestion list leaving out only words similar to the current suggestion. For example, "6463" results in: "nine", "mine", "mind", and so on. But selecting "mind" and pressing Right will leave out only the similar ones: "mind", "minds", "mindy", "minded"... + +#### D-pad Left (←): +_Predictive mode only._ + +- Clear the suggestion filter, if applied. +- When no filter is applied, accept the current word as-is, even if it does not fully match a suggestion, then jump before it. #### 0 key -- In 123 mode: type "0" or hold it to type "+". -- In ABC mode: type secondary punctuation or hold to type "0". -- In Predictive mode: type space or hold to type "0". +- **In 123 mode:** type "0" or hold it to type "+". +- **In ABC mode:** type secondary punctuation or hold to type "0". +- **In Predictive mode:** type space or hold to type "0". #### 1 to 9 keys -- In 123 mode: type the respective number. -- In ABC and Predictive mode: type a letter/punctuation character or hold to type the respective number. +- **In 123 mode:** type the respective number. +- **In ABC and Predictive mode:** type a letter/punctuation character or hold to type the respective number. #### Text Mode Key (Hash/Pound/#): - **Short press:** Cycle input modes (abc → ABC → Predictive → 123) @@ -59,12 +70,12 @@ Select next word suggestion. All functionality is available using the keypad, but for convenience, on touchscreen phones or the ones with customizable function keys, you could also use the on-screen soft keys. #### Left Soft Key: -Open the [Configuration screen](#configuration-options). +Open the [Settings screen](#settings-screen). #### Right Soft Key: Backspace. -## Configuration Options +## Settings Screen On the Configuration screen, you can choose your preferred languages, load a dictionary for Predictive mode or view this manual. To access it: diff --git a/src/io/github/sspanak/tt9/db/DictionaryDb.java b/src/io/github/sspanak/tt9/db/DictionaryDb.java index 7ebd17c7..8d3fe1dd 100644 --- a/src/io/github/sspanak/tt9/db/DictionaryDb.java +++ b/src/io/github/sspanak/tt9/db/DictionaryDb.java @@ -72,15 +72,6 @@ public class DictionaryDb { } - private static void sendSuggestions(Handler handler, ArrayList data) { - Bundle bundle = new Bundle(); - bundle.putStringArrayList("suggestions", data); - Message msg = new Message(); - msg.setData(bundle); - handler.sendMessage(msg); - } - - public static void truncateWords(Handler handler) { new Thread() { @Override @@ -134,6 +125,8 @@ public class DictionaryDb { public static void incrementWordFrequency(Language language, String word, String sequence) throws Exception { + Logger.d("incrementWordFrequency", "Incrementing priority of Word: " + word +" | Sequence: " + sequence); + if (language == null) { throw new InvalidLanguageException(); } @@ -146,18 +139,19 @@ public class DictionaryDb { // If one of them is empty, then this is an invalid operation, // because a digit sequence exist for every word. if (word == null || word.length() == 0 || sequence == null || sequence.length() == 0) { - throw new Exception("Cannot increment word frequency. Word: '" + word + "', Sequence: '" + sequence + "'"); + throw new Exception("Cannot increment word frequency. Word: " + word + " | Sequence: " + sequence); } new Thread() { @Override public void run() { try { - getInstance().wordsDao().incrementFrequency(language.getId(), word, sequence); + int affectedRows = getInstance().wordsDao().incrementFrequency(language.getId(), word.toLowerCase(language.getLocale()), sequence); + Logger.d("incrementWordFrequency", "Affected rows: " + affectedRows); } catch (Exception e) { Logger.e( DictionaryDb.class.getName(), - "Failed incrementing word frequency. Word: '" + word + "', Sequence: '" + sequence + "'. " + e.getMessage() + "Failed incrementing word frequency. Word: " + word + " | Sequence: " + sequence + ". " + e.getMessage() ); } } @@ -165,46 +159,93 @@ public class DictionaryDb { } - public static void getSuggestions(Handler handler, Language language, String sequence, int minimumWords, int maximumWords) { + private static ArrayList getSuggestionsExact(Language language, String sequence, String word, int maximumWords) { + long start = System.currentTimeMillis(); + List exactMatches = getInstance().wordsDao().getMany( + language.getId(), + maximumWords, + sequence, + word == null || word.equals("") ? null : word + ); + Logger.d( + "db.getSuggestionsExact", + "Exact matches: " + exactMatches.size() + ". Time: " + (System.currentTimeMillis() - start) + " ms" + ); + + ArrayList suggestions = new ArrayList<>(); + for (Word w : exactMatches) { + Logger.d("db.getSuggestions", "exact match: " + w.word + " | priority: " + w.frequency); + suggestions.add(w.word); + } + + return suggestions; + } + + + private static ArrayList getSuggestionsFuzzy(Language language, String sequence, String word, int maximumWords) { + long start = System.currentTimeMillis(); + List extraWords = getInstance().wordsDao().getFuzzy( + language.getId(), + maximumWords, + sequence, + word == null || word.equals("") ? null : word + ); + Logger.d( + "db.getSuggestionsFuzzy", + "Fuzzy matches: " + extraWords.size() + ". Time: " + (System.currentTimeMillis() - start) + " ms" + ); + + ArrayList suggestions = new ArrayList<>(); + for (Word w : extraWords) { + Logger.d( + "db.getSuggestions", + "fuzzy match: " + w.word + " | sequence: " + w.sequence + " | priority: " + w.frequency + ); + suggestions.add(w.word); + } + + return suggestions; + } + + + private static void sendSuggestions(Handler handler, ArrayList data) { + Bundle bundle = new Bundle(); + bundle.putStringArrayList("suggestions", data); + Message msg = new Message(); + msg.setData(bundle); + handler.sendMessage(msg); + } + + + public static void getSuggestions(Handler handler, Language language, String sequence, String word, int minimumWords, int maximumWords) { final int minWords = Math.max(minimumWords, 0); final int maxWords = Math.max(maximumWords, minimumWords); + if (sequence == null || sequence.length() == 0) { + Logger.w("tt9/db.getSuggestions", "Attempting to get suggestions for an empty sequence."); + sendSuggestions(handler, new ArrayList<>()); + return; + } + + if (language == null) { + Logger.w("tt9/db.getSuggestions", "Attempting to get suggestions for NULL language."); + sendSuggestions(handler, new ArrayList<>()); + return; + } + new Thread() { @Override public void run() { - if (sequence == null || sequence.length() == 0) { - Logger.w("tt9/db.getSuggestions", "Attempting to get suggestions for an empty sequence."); - sendSuggestions(handler, new ArrayList<>()); - return; - } - - if (language == null) { - Logger.w("tt9/db.getSuggestions", "Attempting to get suggestions for NULL language."); - sendSuggestions(handler, new ArrayList<>()); - return; - } - // get exact sequence matches, for example: "9422" -> "what" - List exactMatches = getInstance().wordsDao().getMany(language.getId(), sequence, maxWords); - Logger.d("db.getSuggestions", "Exact matches: " + exactMatches.size()); + ArrayList suggestions = getSuggestionsExact(language, sequence, word, maxWords); - ArrayList suggestions = new ArrayList<>(); - for (Word word : exactMatches) { - Logger.d("db.getSuggestions", "exact match: " + word.word + ", priority: " + word.frequency); - suggestions.add(word.word); - } // if the exact matches are too few, add some more words that start with the same characters, - // for example: "rol" => "roll", "roller", "rolling", ... - if (exactMatches.size() < minWords && sequence.length() >= 2) { - int extraWordsNeeded = minWords - exactMatches.size(); - List extraWords = getInstance().wordsDao().getFuzzy(language.getId(), sequence, extraWordsNeeded); - Logger.d("db.getSuggestions", "Fuzzy matches: " + extraWords.size()); - - for (Word word : extraWords) { - Logger.d("db.getSuggestions", "fuzzy match: " + word.word + ", sequence: " + word.sequence); - suggestions.add(word.word); - } + // for example: "rol" -> "roll", "roller", "rolling", ... + if (suggestions.size() < minWords && sequence.length() >= 2) { + suggestions.addAll( + getSuggestionsFuzzy(language, sequence, word, minWords - suggestions.size()) + ); } if (suggestions.size() == 0) { @@ -215,6 +256,5 @@ public class DictionaryDb { sendSuggestions(handler, suggestions); } }.start(); - } } diff --git a/src/io/github/sspanak/tt9/db/WordsDao.java b/src/io/github/sspanak/tt9/db/WordsDao.java index f67d9e18..cae9eccc 100644 --- a/src/io/github/sspanak/tt9/db/WordsDao.java +++ b/src/io/github/sspanak/tt9/db/WordsDao.java @@ -12,20 +12,26 @@ interface WordsDao { @Query( "SELECT * " + "FROM words " + - "WHERE lang = :langId AND seq = :sequence " + + "WHERE " + + "lang = :langId " + + "AND seq = :sequence " + + "AND (:word IS NULL OR word LIKE :word || '%') " + "ORDER BY freq DESC " + "LIMIT :limit" ) - List getMany(int langId, String sequence, int limit); + List getMany(int langId, int limit, String sequence, String word); @Query( "SELECT * " + "FROM words " + - "WHERE lang = :langId AND seq > :sequence AND seq <= :sequence || '99' " + - "ORDER BY freq DESC, seq ASC " + + "WHERE " + + "lang = :langId " + + "AND seq > :sequence AND seq <= :sequence || '99' " + + "AND (:word IS NULL OR word LIKE :word || '%') " + + "ORDER BY freq DESC, LENGTH(seq) ASC, seq ASC " + "LIMIT :limit" ) - List getFuzzy(int langId, String sequence, int limit); + List getFuzzy(int langId, int limit, String sequence, String word); @Insert void insert(Word word); @@ -36,7 +42,7 @@ interface WordsDao { @Query( "UPDATE words " + "SET freq = (SELECT IFNULL(MAX(freq), 0) FROM words WHERE lang = :langId AND seq = :sequence AND word <> :word) + 1 " + - "WHERE lang = :langId AND word = :word " + "WHERE lang = :langId AND word = :word AND seq = :sequence" ) - void incrementFrequency(int langId, String word, String sequence); + int incrementFrequency(int langId, String word, String sequence); } diff --git a/src/io/github/sspanak/tt9/ime/KeyPadHandler.java b/src/io/github/sspanak/tt9/ime/KeyPadHandler.java index 23a098c4..9c7c6359 100644 --- a/src/io/github/sspanak/tt9/ime/KeyPadHandler.java +++ b/src/io/github/sspanak/tt9/ime/KeyPadHandler.java @@ -186,7 +186,8 @@ abstract class KeyPadHandler extends InputMethodService { || keyCode == KeyEvent.KEYCODE_STAR || keyCode == KeyEvent.KEYCODE_POUND || (isNumber(keyCode) && shouldTrackNumPress()) - || ((keyCode == KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_DOWN) && shouldTrackArrows()) + || ((keyCode == KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_DOWN) && shouldTrackUpDown()) + || ((keyCode == KeyEvent.KEYCODE_DPAD_LEFT || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) && shouldTrackLeftRight()) || (mEditing != EDITING_NOSHOW && keyCode == KeyEvent.KEYCODE_DPAD_CENTER) ) { return true; @@ -296,6 +297,8 @@ abstract class KeyPadHandler extends InputMethodService { case KeyEvent.KEYCODE_DPAD_CENTER: return onOK(); case KeyEvent.KEYCODE_DPAD_UP: return onUp(); case KeyEvent.KEYCODE_DPAD_DOWN: return onDown(); + case KeyEvent.KEYCODE_DPAD_LEFT: return onLeft(); + case KeyEvent.KEYCODE_DPAD_RIGHT: return onRight(); case KeyEvent.KEYCODE_1: case KeyEvent.KEYCODE_2: case KeyEvent.KEYCODE_3: @@ -370,7 +373,8 @@ abstract class KeyPadHandler extends InputMethodService { } // toggle handlers - abstract protected boolean shouldTrackArrows(); + abstract protected boolean shouldTrackUpDown(); + abstract protected boolean shouldTrackLeftRight(); abstract protected boolean shouldTrackNumPress(); // default hardware key handlers @@ -378,6 +382,8 @@ abstract class KeyPadHandler extends InputMethodService { abstract public boolean onOK(); abstract protected boolean onUp(); abstract protected boolean onDown(); + abstract protected boolean onLeft(); + abstract protected boolean onRight(); abstract protected boolean onNumber(int key, boolean hold, boolean repeat); abstract protected boolean onStar(); abstract protected boolean onPound(); diff --git a/src/io/github/sspanak/tt9/ime/TraditionalT9.java b/src/io/github/sspanak/tt9/ime/TraditionalT9.java index 5fbca7cf..cc56d5ae 100644 --- a/src/io/github/sspanak/tt9/ime/TraditionalT9.java +++ b/src/io/github/sspanak/tt9/ime/TraditionalT9.java @@ -37,7 +37,6 @@ public class TraditionalT9 extends KeyPadHandler { private SoftKeyHandler softKeyHandler = null; - public static Context getMainContext() { return self.getApplicationContext(); } @@ -75,6 +74,8 @@ public class TraditionalT9 extends KeyPadHandler { protected void onRestart(EditorInfo inputField) { + determineNextTextCase(); + // determine the valid state for the current input field and preferences determineAllowedInputModes(inputField); determineAllowedTextCases(); @@ -120,7 +121,7 @@ public class TraditionalT9 extends KeyPadHandler { if (mInputMode.onBackspace()) { getSuggestions(); } else { - commitCurrentSuggestion(); + commitCurrentSuggestion(false); super.sendDownUpKeyEvents(KeyEvent.KEYCODE_DEL); } @@ -130,25 +131,49 @@ public class TraditionalT9 extends KeyPadHandler { public boolean onOK() { - Logger.d("onOK", "enter handler"); + if (isSuggestionViewHidden() && currentInputConnection != null) { + return sendDefaultEditorAction(false); + } - acceptCurrentSuggestion(); + mInputMode.onAcceptSuggestion(mLanguage, mSuggestionView.getCurrentSuggestion()); + commitCurrentSuggestion(); + determineNextTextCase(); resetKeyRepeat(); - mInputMode.reset(); - return !isSuggestionViewHidden(); + return true; } protected boolean onUp() { return previousSuggestion(); } - - protected boolean onDown() { return nextSuggestion(); } + protected boolean onLeft() { + if (mInputMode.isStemFilterOn()) { + mInputMode.clearStemFilter(); + mInputMode.getSuggestionsAsync(handleSuggestionsAsync, mLanguage, getComposingText()); + } else { + jumpBeforeComposingText(); + } + + return true; + } + + protected boolean onRight() { + String filter = mSuggestionView.getCurrentSuggestion(); + + if (mInputMode.setStemFilter(mLanguage, filter)) { + mInputMode.getSuggestionsAsync(handleSuggestionsAsync, mLanguage, filter); + } else if (filter.length() == 0) { + mInputMode.reset(); + } + + return true; + } + /** * onNumber * @@ -159,7 +184,13 @@ public class TraditionalT9 extends KeyPadHandler { */ protected boolean onNumber(int key, boolean hold, boolean repeat) { if (mInputMode.shouldAcceptCurrentSuggestion(key, hold, repeat)) { - acceptCurrentSuggestion(); + mInputMode.onAcceptSuggestion(mLanguage, getComposingText()); + commitCurrentSuggestion(false); + determineNextTextCase(); + } else if (!InputFieldHelper.isThereText(currentInputConnection)) { + // it would have been nice to determine the text case on every key press, + // but it is somewhat resource-intensive + determineNextTextCase(); } if (!mInputMode.onNumber(mLanguage, key, hold, repeat)) { @@ -172,7 +203,7 @@ public class TraditionalT9 extends KeyPadHandler { } if (mInputMode.getWord() != null) { - setText(mInputMode.getWord()); + commitText(mInputMode.getWord()); } else { getSuggestions(); } @@ -182,13 +213,13 @@ public class TraditionalT9 extends KeyPadHandler { protected boolean onPound() { - setText("#"); + commitText("#"); return true; } protected boolean onStar() { - setText("*"); + commitText("*"); return true; } @@ -228,8 +259,12 @@ public class TraditionalT9 extends KeyPadHandler { } - protected boolean shouldTrackArrows() { - return mEditing != EDITING_NOSHOW && !isSuggestionViewHidden(); + protected boolean shouldTrackUpDown() { + return mEditing != EDITING_NOSHOW && !isSuggestionViewHidden() && mInputMode.shouldTrackUpDown(); + } + + protected boolean shouldTrackLeftRight() { + return mEditing != EDITING_NOSHOW && !isSuggestionViewHidden() && mInputMode.shouldTrackLeftRight(); } @@ -261,44 +296,17 @@ public class TraditionalT9 extends KeyPadHandler { return true; } - private void handleSuggestions(ArrayList suggestions, int maxWordLength) { - setSuggestions(suggestions); - - // Put the first suggestion in the text field, - // but cut it off to the length of the sequence (how many keys were pressed), - // for a more intuitive experience. - String word = mSuggestionView.getCurrentSuggestion(); - word = word.substring(0, Math.min(maxWordLength, word.length())); - setComposingText(word); - } - - private final Handler handleSuggestionsAsync = new Handler(Looper.getMainLooper()) { - @Override - public void handleMessage(Message msg) { - handleSuggestions( - msg.getData().getStringArrayList("suggestions"), - msg.getData().getInt("maxWordLength", 1000) - ); - } - }; - - - private void acceptCurrentSuggestion() { - mInputMode.onAcceptSuggestion(mLanguage, mSuggestionView.getCurrentSuggestion()); - commitCurrentSuggestion(); - } - private void commitCurrentSuggestion() { - // commit the current suggestion to the input field - if (!isSuggestionViewHidden()) { - if (mSuggestionView.getCurrentSuggestion().equals(" ")) { - // finishComposingText() seems to ignore a single space, - // so we have to force commit it. - setText(" "); - } else { - currentInputConnection.finishComposingText(); + commitCurrentSuggestion(true); + } + + private void commitCurrentSuggestion(boolean entireSuggestion) { + if (!isSuggestionViewHidden() && currentInputConnection != null) { + if (entireSuggestion) { + setComposingTextFromCurrentSuggestion(); } + currentInputConnection.finishComposingText(); } setSuggestions(null); @@ -309,17 +317,39 @@ public class TraditionalT9 extends KeyPadHandler { setSuggestions(null); if (currentInputConnection != null) { - setComposingTextFromCurrentSuggestion(); + setComposingText(""); currentInputConnection.finishComposingText(); } } + private void getSuggestions() { if (!mInputMode.getSuggestionsAsync(handleSuggestionsAsync, mLanguage, mSuggestionView.getCurrentSuggestion())) { - handleSuggestions(mInputMode.getSuggestions(), 1); + handleSuggestions(mInputMode.getSuggestions()); } } + + private void handleSuggestions(ArrayList suggestions) { + setSuggestions(suggestions); + + // Put the first suggestion in the text field, + // but cut it off to the length of the sequence (how many keys were pressed), + // for a more intuitive experience. + String word = mSuggestionView.getCurrentSuggestion(); + word = word.substring(0, Math.min(mInputMode.getSequenceLength(), word.length())); + setComposingText(word); + } + + + private final Handler handleSuggestionsAsync = new Handler(Looper.getMainLooper()) { + @Override + public void handleMessage(Message msg) { + handleSuggestions(msg.getData().getStringArrayList("suggestions")); + } + }; + + private void setSuggestions(List suggestions) { if (mSuggestionView == null) { return; @@ -332,14 +362,35 @@ public class TraditionalT9 extends KeyPadHandler { setCandidatesViewShown(show); } - private void setText(String text) { - if (text != null) { - currentInputConnection.commitText(text, text.length()); + + private void commitText(String text) { + if (text != null && currentInputConnection != null) { + currentInputConnection.commitText(text, 1); } } + + private String getComposingText() { + String text = mSuggestionView.getCurrentSuggestion(); + if (text.length() > 0 && text.length() > mInputMode.getSequenceLength()) { + text = text.substring(0, mInputMode.getSequenceLength()); + } + + return text; + } + + private void setComposingText(String text) { - currentInputConnection.setComposingText(text, 1); + if (text != null && currentInputConnection != null) { + currentInputConnection.setComposingText(text, 1); + } + } + + + private void setComposingTextFromCurrentSuggestion() { + if (!isSuggestionViewHidden()) { + setComposingText(mSuggestionView.getCurrentSuggestion()); + } } @@ -356,7 +407,7 @@ public class TraditionalT9 extends KeyPadHandler { mTextCase = allowedTextCases.get(modeIndex); mSuggestionView.changeCase(mTextCase, mLanguage.getLocale()); - setComposingTextFromCurrentSuggestion(); + setComposingText(getComposingText()); // no mistake, this forces the new text case } // make "abc" and "ABC" separate modes from user perspective else if (mInputMode.isABC() && mTextCase == InputMode.CASE_LOWER) { @@ -375,12 +426,6 @@ public class TraditionalT9 extends KeyPadHandler { UI.updateStatusIcon(this, mLanguage, mInputMode, mTextCase); } - private void setComposingTextFromCurrentSuggestion() { - if (!isSuggestionViewHidden()) { - setComposingText(mSuggestionView.getCurrentSuggestion()); - } - } - private void nextLang() { if (mEditing == EDITING_STRICT_NUMERIC || mEditing == EDITING_DIALER) { @@ -403,6 +448,17 @@ public class TraditionalT9 extends KeyPadHandler { } + private void jumpBeforeComposingText() { + if (currentInputConnection != null) { + currentInputConnection.setComposingText(getComposingText(), 0); + currentInputConnection.finishComposingText(); + } + + setSuggestions(null); + mInputMode.reset(); + } + + private void determineAllowedInputModes(EditorInfo inputField) { allowedInputModes = InputFieldHelper.determineInputModes(inputField); @@ -431,8 +487,19 @@ public class TraditionalT9 extends KeyPadHandler { } + private void determineNextTextCase() { + int nextTextCase = mInputMode.getNextWordTextCase( + mTextCase, + InputFieldHelper.isThereText(currentInputConnection), + (String) currentInputConnection.getTextBeforeCursor(50, 0) + ); + + mTextCase = nextTextCase != -1 ? nextTextCase : mTextCase; + } + + private void showAddWord() { - acceptCurrentSuggestion(); + currentInputConnection.finishComposingText(); clearSuggestions(); UI.showAddWordDialog(this, mLanguage.getId(), InputFieldHelper.getSurroundingWord(currentInputConnection)); @@ -453,7 +520,7 @@ public class TraditionalT9 extends KeyPadHandler { try { Logger.d("restoreAddedWordIfAny", "Restoring word: '" + word + "'..."); - setText(word); + commitText(word); mInputMode.reset(); } catch (Exception e) { Logger.w("tt9/restoreLastWord", "Could not restore the last added word. " + e.getMessage()); diff --git a/src/io/github/sspanak/tt9/ime/modes/InputMode.java b/src/io/github/sspanak/tt9/ime/modes/InputMode.java index 6cd007a9..c673b646 100644 --- a/src/io/github/sspanak/tt9/ime/modes/InputMode.java +++ b/src/io/github/sspanak/tt9/ime/modes/InputMode.java @@ -16,9 +16,9 @@ abstract public class InputMode { public static final int MODE_123 = 2; // text case - public static final int CASE_LOWER = 0; + public static final int CASE_UPPER = 0; public static final int CASE_CAPITALIZE = 1; - public static final int CASE_UPPER = 2; + public static final int CASE_LOWER = 2; protected ArrayList allowedTextCases = new ArrayList<>(); // data @@ -47,10 +47,9 @@ abstract public class InputMode { public void onAcceptSuggestion(Language language, String suggestion) {} public ArrayList getSuggestions() { return suggestions; } public boolean getSuggestionsAsync(Handler handler, Language language, String lastWord) { return false; } - protected void sendSuggestions(Handler handler, ArrayList suggestions, int maxWordLength) { + protected void sendSuggestions(Handler handler, ArrayList suggestions) { Bundle bundle = new Bundle(); bundle.putStringArrayList("suggestions", suggestions); - bundle.putInt("maxWordLength", maxWordLength); Message msg = new Message(); msg.setData(bundle); handler.sendMessage(msg); @@ -67,11 +66,23 @@ abstract public class InputMode { // Utility abstract public int getId(); public ArrayList getAllowedTextCases() { return allowedTextCases; } + // Perform any special logic to determine the text case of the next word, or return "-1" if there is no need to change it. + public int getNextWordTextCase(int currentTextCase, boolean isThereText, String textBeforeCursor) { return -1; } + abstract public int getSequenceLength(); // The number of key presses for the current word. public void reset() { suggestions = new ArrayList<>(); word = null; } + + // Stem filtering. + // Where applicable, return "true" if the mode supports it and the operation was possible. + public boolean isStemFilterOn() { return false; } + public void clearStemFilter() {} + public boolean setStemFilter(Language language, String stem) { return false; } + public boolean shouldTrackNumPress() { return true; } + public boolean shouldTrackUpDown() { return false; } + public boolean shouldTrackLeftRight() { return false; } public boolean shouldAcceptCurrentSuggestion(int key, boolean hold, boolean repeat) { return false; } public boolean shouldSelectNextSuggestion() { return false; } } diff --git a/src/io/github/sspanak/tt9/ime/modes/Mode123.java b/src/io/github/sspanak/tt9/ime/modes/Mode123.java index 6d264415..b308b72a 100644 --- a/src/io/github/sspanak/tt9/ime/modes/Mode123.java +++ b/src/io/github/sspanak/tt9/ime/modes/Mode123.java @@ -24,5 +24,6 @@ public class Mode123 extends InputMode { final public boolean is123() { return true; } + public int getSequenceLength() { return 0; } public boolean shouldTrackNumPress() { return false; } } diff --git a/src/io/github/sspanak/tt9/ime/modes/ModeABC.java b/src/io/github/sspanak/tt9/ime/modes/ModeABC.java index 512599b2..fb3b3be9 100644 --- a/src/io/github/sspanak/tt9/ime/modes/ModeABC.java +++ b/src/io/github/sspanak/tt9/ime/modes/ModeABC.java @@ -31,7 +31,11 @@ public class ModeABC extends InputMode { final public boolean isABC() { return true; } + public int getSequenceLength() { return 1; } + public boolean shouldAcceptCurrentSuggestion(int key, boolean hold, boolean repeat) { return hold || !repeat; } + public boolean shouldTrackUpDown() { return true; } + public boolean shouldTrackLeftRight() { return true; } public boolean shouldSelectNextSuggestion() { return shouldSelectNextLetter; } diff --git a/src/io/github/sspanak/tt9/ime/modes/ModePredictive.java b/src/io/github/sspanak/tt9/ime/modes/ModePredictive.java index 790bdc1b..6144c0ef 100644 --- a/src/io/github/sspanak/tt9/ime/modes/ModePredictive.java +++ b/src/io/github/sspanak/tt9/ime/modes/ModePredictive.java @@ -5,7 +5,8 @@ import android.os.Looper; import android.os.Message; import java.util.ArrayList; -import java.util.Collections; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import io.github.sspanak.tt9.Logger; import io.github.sspanak.tt9.db.DictionaryDb; @@ -16,33 +17,47 @@ import io.github.sspanak.tt9.preferences.T9Preferences; public class ModePredictive extends InputMode { public int getId() { return MODE_PREDICTIVE; } - private Language currentLanguage = null; + private boolean isEmoji = false; private String digitSequence = ""; - private boolean isEmoticon = false; + + // stem filter + private String stemFilter = ""; + private final int STEM_FILTER_MIN_LENGTH = 2; + + // async suggestion handling + private Language currentLanguage = null; private String lastInputFieldWord = ""; private static Handler handleSuggestionsExternal; - ModePredictive() { + allowedTextCases.add(CASE_UPPER); allowedTextCases.add(CASE_CAPITALIZE); allowedTextCases.add(CASE_LOWER); - allowedTextCases.add(CASE_UPPER); } public boolean onBackspace() { + if (stemFilter.length() < STEM_FILTER_MIN_LENGTH) { + stemFilter = ""; + } + if (digitSequence.length() < 1) { + stemFilter = ""; return false; } digitSequence = digitSequence.substring(0, digitSequence.length() - 1); + if (stemFilter.length() > digitSequence.length()) { + stemFilter = stemFilter.substring(0, digitSequence.length() - 1); + } + return true; } public boolean onNumber(Language l, int key, boolean hold, boolean repeat) { - isEmoticon = false; + isEmoji = false; if (hold) { // hold to type any digit @@ -55,8 +70,8 @@ public class ModePredictive extends InputMode { } else if (key == 1 && repeat) { // emoticons reset(); - isEmoticon = true; - suggestions = Punctuation.Emoticons; + isEmoji = true; + suggestions = Punctuation.Emoji; } else { // words @@ -71,6 +86,7 @@ public class ModePredictive extends InputMode { public void reset() { super.reset(); digitSequence = ""; + stemFilter = ""; } @@ -78,6 +94,11 @@ public class ModePredictive extends InputMode { return true; } + public int getSequenceLength() { return isEmoji ? 2 : digitSequence.length(); } + + public boolean shouldTrackUpDown() { return true; } + public boolean shouldTrackLeftRight() { return true; } + /** * shouldAcceptCurrentSuggestion @@ -97,6 +118,47 @@ public class ModePredictive extends InputMode { } + /** + * isStemFilterOn + * Returns "true" if a filter was applied using "setStem()". + */ + public boolean isStemFilterOn() { + return stemFilter.length() > 0; + } + + + /** + * clearStemFilter + * Do not filter the suggestions by the word set using "setStem()", use only the digit sequence. + */ + public void clearStemFilter() { + stemFilter = ""; + } + + + /** + * setStemFilter + * Filter the possible suggestions by the given stem. The stem must have + * a minimum length of STEM_FILTER_MIN_LENGTH. + * + * Note that you need to manually get the suggestions again to obtain a filtered list. + */ + public boolean setStemFilter(Language language, String stem) { + if (language == null || stem == null || stem.length() < STEM_FILTER_MIN_LENGTH) { + return false; + } + + try { + digitSequence = language.getDigitSequenceForWord(stem); + stemFilter = stem; + return true; + } catch (Exception e) { + Logger.w("tt9/setStemFilter", "Ignoring invalid stem filter: " + stem + ". " + e.getMessage()); + return false; + } + } + + /** * getSuggestionsAsync * Queries the dictionary database for a list of suggestions matching the current language and @@ -106,8 +168,8 @@ public class ModePredictive extends InputMode { * See: generateSuggestionWhenNone() */ public boolean getSuggestionsAsync(Handler handler, Language language, String lastWord) { - if (isEmoticon) { - super.sendSuggestions(handler, suggestions, 2); + if (isEmoji) { + super.sendSuggestions(handler, suggestions); return true; } @@ -124,6 +186,7 @@ public class ModePredictive extends InputMode { handleSuggestions, language, digitSequence, + stemFilter, T9Preferences.getInstance().getSuggestionsMin(), T9Preferences.getInstance().getSuggestionsMax() ); @@ -143,7 +206,7 @@ public class ModePredictive extends InputMode { ArrayList suggestions = msg.getData().getStringArrayList("suggestions"); suggestions = generateSuggestionWhenNone(suggestions, currentLanguage, lastInputFieldWord); - ModePredictive.super.sendSuggestions(handleSuggestionsExternal, suggestions, digitSequence.length()); + ModePredictive.super.sendSuggestions(handleSuggestionsExternal, suggestions); } }; @@ -152,9 +215,9 @@ public class ModePredictive extends InputMode { * When there are no matching suggestions after the last key press, generate a list of possible * ones, so that the user can complete the missing word. */ - private ArrayList generateSuggestionWhenNone(ArrayList suggestions, Language language, String lastWord) { + private ArrayList generateSuggestionWhenNone(ArrayList suggestions, Language language, String word) { if ( - (lastWord == null || lastWord.length() == 0) || + (word == null || word.length() == 0) || (suggestions != null && suggestions.size() > 0) || digitSequence.length() == 0 || digitSequence.charAt(0) == '1' @@ -162,15 +225,23 @@ public class ModePredictive extends InputMode { return suggestions; } - lastWord = lastWord.substring(0, Math.min(digitSequence.length() - 1, lastWord.length())); - try { - int lastDigit = digitSequence.charAt(digitSequence.length() - 1) - '0'; - lastWord += language.getKeyCharacters(lastDigit).get(0); - } catch (Exception e) { - lastWord += digitSequence.charAt(digitSequence.length() - 1); + // append all letters for the last key + word = word.substring(0, Math.min(digitSequence.length() - 1, word.length())); + ArrayList generatedSuggestions = new ArrayList<>(); + int lastSequenceDigit = digitSequence.charAt(digitSequence.length() - 1) - '0'; + + for (String keyLetter : language.getKeyCharacters(lastSequenceDigit)) { + if (keyLetter.charAt(0) - '0' > '9') { // append only letters, not numbers + generatedSuggestions.add(word + keyLetter); + } } - return new ArrayList<>(Collections.singletonList(lastWord)); + // if there are no letters for this key, just append the number + if (generatedSuggestions.size() == 0) { + generatedSuggestions.add(word + digitSequence.charAt(digitSequence.length() - 1)); + } + + return generatedSuggestions; } @@ -179,7 +250,7 @@ public class ModePredictive extends InputMode { * Bring this word up in the suggestions list next time. */ public void onAcceptSuggestion(Language language, String currentWord) { - digitSequence = ""; + reset(); if (currentWord.length() == 0) { Logger.i("acceptCurrentSuggestion", "Current word is empty. Nothing to accept."); @@ -193,4 +264,30 @@ public class ModePredictive extends InputMode { Logger.e("tt9/ModePredictive", "Failed incrementing priority of word: '" + currentWord + "'. " + e.getMessage()); } } + + /** + * getNextWordTextCase + * 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 getNextWordTextCase(int currentTextCase, boolean isThereText, String textBeforeCursor) { + // If the user wants to type in uppercase, this must be for a reason, so we better not override it. + if (currentTextCase == CASE_UPPER) { + return -1; + } + + // start of text + if (!isThereText) { + return CASE_CAPITALIZE; + } + + // start of sentence, excluding after "..." + Matcher endOfSentenceMatch = Pattern.compile("(? Emoticons = new ArrayList<>(Arrays.asList( - "👍", ":)", ":D", ";)", ":(", ":P" + final public static ArrayList Emoji = new ArrayList<>(Arrays.asList( + "👍", "🙂", "😀", "😉", "🙁", "😢", "😛", "😬" )); - }