diff --git a/app/src/main/java/io/github/sspanak/tt9/db/DictionaryLoader.java b/app/src/main/java/io/github/sspanak/tt9/db/DictionaryLoader.java index eefec04a..97497fd7 100644 --- a/app/src/main/java/io/github/sspanak/tt9/db/DictionaryLoader.java +++ b/app/src/main/java/io/github/sspanak/tt9/db/DictionaryLoader.java @@ -2,6 +2,7 @@ package io.github.sspanak.tt9.db; import android.content.Context; import android.content.res.AssetManager; +import android.inputmethodservice.InputMethodService; import android.os.Bundle; import android.os.Handler; @@ -10,9 +11,6 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.Locale; -import io.github.sspanak.tt9.util.ConsumerCompat; -import io.github.sspanak.tt9.util.Logger; -import io.github.sspanak.tt9.util.Timer; import io.github.sspanak.tt9.db.entities.WordBatch; import io.github.sspanak.tt9.db.entities.WordFile; import io.github.sspanak.tt9.db.exceptions.DictionaryImportAbortedException; @@ -21,14 +19,16 @@ import io.github.sspanak.tt9.db.sqlite.DeleteOps; import io.github.sspanak.tt9.db.sqlite.InsertOps; import io.github.sspanak.tt9.db.sqlite.SQLiteOpener; import io.github.sspanak.tt9.db.sqlite.Tables; -import io.github.sspanak.tt9.ime.TraditionalT9; import io.github.sspanak.tt9.languages.EmojiLanguage; +import io.github.sspanak.tt9.languages.Language; import io.github.sspanak.tt9.languages.exceptions.InvalidLanguageCharactersException; import io.github.sspanak.tt9.languages.exceptions.InvalidLanguageException; -import io.github.sspanak.tt9.languages.Language; import io.github.sspanak.tt9.preferences.SettingsStore; import io.github.sspanak.tt9.ui.DictionaryLoadingBar; import io.github.sspanak.tt9.ui.UI; +import io.github.sspanak.tt9.util.ConsumerCompat; +import io.github.sspanak.tt9.util.Logger; +import io.github.sspanak.tt9.util.Timer; public class DictionaryLoader { private static final String LOG_TAG = "DictionaryLoader"; @@ -109,7 +109,7 @@ public class DictionaryLoader { } - public static void autoLoad(TraditionalT9 context, Language language) { + public static void autoLoad(InputMethodService context, Language language) { if (getInstance(context).isRunning()) { return; } diff --git a/app/src/main/java/io/github/sspanak/tt9/db/WordStore.java b/app/src/main/java/io/github/sspanak/tt9/db/WordStore.java index 1254b104..6757497f 100644 --- a/app/src/main/java/io/github/sspanak/tt9/db/WordStore.java +++ b/app/src/main/java/io/github/sspanak/tt9/db/WordStore.java @@ -6,8 +6,6 @@ import androidx.annotation.NonNull; import java.util.ArrayList; -import io.github.sspanak.tt9.util.Logger; -import io.github.sspanak.tt9.util.Timer; import io.github.sspanak.tt9.db.entities.Word; import io.github.sspanak.tt9.db.entities.WordList; import io.github.sspanak.tt9.db.sqlite.DeleteOps; @@ -15,13 +13,14 @@ import io.github.sspanak.tt9.db.sqlite.InsertOps; import io.github.sspanak.tt9.db.sqlite.ReadOps; import io.github.sspanak.tt9.db.sqlite.SQLiteOpener; import io.github.sspanak.tt9.db.sqlite.UpdateOps; -import io.github.sspanak.tt9.ime.TraditionalT9; import io.github.sspanak.tt9.languages.EmojiLanguage; import io.github.sspanak.tt9.languages.Language; import io.github.sspanak.tt9.languages.NullLanguage; -import io.github.sspanak.tt9.util.Text; import io.github.sspanak.tt9.preferences.SettingsStore; import io.github.sspanak.tt9.ui.dialogs.AddWordDialog; +import io.github.sspanak.tt9.util.Logger; +import io.github.sspanak.tt9.util.Text; +import io.github.sspanak.tt9.util.Timer; public class WordStore { @@ -32,7 +31,7 @@ public class WordStore { private ReadOps readOps = null; - public WordStore(@NonNull Context context) { + private WordStore(@NonNull Context context) { try { sqlite = SQLiteOpener.getInstance(context); sqlite.getDb(); @@ -44,9 +43,8 @@ public class WordStore { } - public static synchronized WordStore getInstance(Context context) { + public static synchronized WordStore getInstance(@NonNull Context context) { if (self == null) { - context = context == null ? TraditionalT9.getMainContext() : context; self = new WordStore(context); } diff --git a/app/src/main/java/io/github/sspanak/tt9/ime/AbstractHandler.java b/app/src/main/java/io/github/sspanak/tt9/ime/AbstractHandler.java new file mode 100644 index 00000000..35ea0e9d --- /dev/null +++ b/app/src/main/java/io/github/sspanak/tt9/ime/AbstractHandler.java @@ -0,0 +1,35 @@ +package io.github.sspanak.tt9.ime; + +import android.inputmethodservice.InputMethodService; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +import io.github.sspanak.tt9.preferences.SettingsStore; + +abstract public class AbstractHandler extends InputMethodService { + // hardware key handlers + abstract protected boolean onBack(); + abstract public boolean onBackspace(); + abstract public boolean onHotkey(int keyCode, boolean repeat, boolean validateOnly); + abstract protected boolean onNumber(int key, boolean hold, int repeat); + abstract public boolean onOK(); + abstract public boolean onText(String text, boolean validateOnly); // used for "#", "*" and whatnot + + // helpers + abstract public SettingsStore getSettings(); + abstract protected void onInit(); + abstract protected void onStart(InputConnection inputConnection, EditorInfo inputField); + abstract protected void onFinishTyping(); + abstract protected void onStop(); + abstract protected void setInputField(InputConnection inputConnection, EditorInfo inputField); + + // UI + abstract protected View createMainView(); + abstract protected void createSuggestionBar(View mainView); + abstract protected void forceShowWindowIfHidden(); + abstract protected void renderMainView(); + abstract protected void setStatusText(String status); + abstract protected boolean shouldBeVisible(); + abstract protected boolean shouldBeOff(); +} diff --git a/app/src/main/java/io/github/sspanak/tt9/ime/HotkeyHandler.java b/app/src/main/java/io/github/sspanak/tt9/ime/HotkeyHandler.java new file mode 100644 index 00000000..8c22da64 --- /dev/null +++ b/app/src/main/java/io/github/sspanak/tt9/ime/HotkeyHandler.java @@ -0,0 +1,338 @@ +package io.github.sspanak.tt9.ime; + +import android.view.KeyEvent; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +import io.github.sspanak.tt9.R; +import io.github.sspanak.tt9.db.DictionaryLoader; +import io.github.sspanak.tt9.ime.helpers.TextField; +import io.github.sspanak.tt9.ime.modes.InputMode; +import io.github.sspanak.tt9.ime.modes.ModeABC; +import io.github.sspanak.tt9.ime.modes.ModePredictive; +import io.github.sspanak.tt9.languages.LanguageCollection; +import io.github.sspanak.tt9.languages.LanguageKind; +import io.github.sspanak.tt9.preferences.helpers.Hotkeys; +import io.github.sspanak.tt9.ui.UI; + +public abstract class HotkeyHandler extends TypingHandler { + private boolean isSystemRTL = false; + + + @Override + protected void onInit() { + if (settings.areHotkeysInitialized()) { + Hotkeys.setDefault(settings); + } + } + + + @Override + protected void onStart(InputConnection connection, EditorInfo field) { + super.onStart(connection, field); + isSystemRTL = LanguageKind.isRTL(LanguageCollection.getDefault(this)); + } + + + @Override public boolean onBack() { + return settings.getShowSoftNumpad(); + } + + + @Override public boolean onOK() { + suggestionOps.cancelDelayedAccept(); + + if (suggestionOps.isEmpty()) { + int action = textField.getAction(); + return action == TextField.IME_ACTION_ENTER ? appHacks.onEnter() : textField.performAction(action); + } + + onAcceptSuggestionManually(suggestionOps.acceptCurrent(), KeyEvent.KEYCODE_ENTER); + return true; + } + + + public boolean onHotkey(int keyCode, boolean repeat, boolean validateOnly) { + if (keyCode == settings.getKeyAddWord()) { + return onKeyAddWord(validateOnly); + } + + if (keyCode == settings.getKeyChangeKeyboard()) { + return onKeyChangeKeyboard(validateOnly); + } + + if (keyCode == settings.getKeyFilterClear()) { + return onKeyFilterClear(validateOnly); + } + + if (keyCode == settings.getKeyFilterSuggestions()) { + return onKeyFilterSuggestions(validateOnly, repeat); + } + + if (keyCode == settings.getKeyNextLanguage()) { + return onKeyNextLanguage(validateOnly); + } + + if (keyCode == settings.getKeyNextInputMode()) { + return onKeyNextInputMode(validateOnly); + } + + if (keyCode == settings.getKeyPreviousSuggestion()) { + return onKeyScrollSuggestion(validateOnly, true); + } + + if (keyCode == settings.getKeyNextSuggestion()) { + return onKeyScrollSuggestion(validateOnly, false); + } + + if (keyCode == settings.getKeyShowSettings()) { + return onKeyShowSettings(validateOnly); + } + + return false; + } + + + public boolean onKeyAddWord(boolean validateOnly) { + if (!isInputViewShown() || mInputMode.isNumeric()) { + return false; + } + + if (validateOnly) { + return true; + } + + if (DictionaryLoader.getInstance(this).isRunning()) { + UI.toast(this, R.string.dictionary_loading_please_wait); + return true; + } + + suggestionOps.cancelDelayedAccept(); + mInputMode.onAcceptSuggestion(suggestionOps.acceptIncomplete()); + + String word = textField.getSurroundingWord(mLanguage); + if (word.isEmpty()) { + UI.toastLong(this, R.string.add_word_no_selection); + } else { + UI.showAddWordDialog(this, mLanguage.getId(), word); + } + + return true; + } + + + public boolean onKeyChangeKeyboard(boolean validateOnly) { + if (!isInputViewShown()) { + return false; + } + + if (!validateOnly) { + UI.showChangeKeyboardDialog(this); + } + + return true; + } + + + public boolean onKeyFilterClear(boolean validateOnly) { + if (suggestionOps.isEmpty()) { + return false; + } + + if (validateOnly) { + return true; + } + + suggestionOps.cancelDelayedAccept(); + + if (mInputMode.clearWordStem()) { + mInputMode.loadSuggestions(this::getSuggestions, suggestionOps.getCurrent(mInputMode.getSequenceLength())); + return true; + } + + mInputMode.onAcceptSuggestion(suggestionOps.acceptIncomplete()); + resetKeyRepeat(); + + return true; + } + + + public boolean onKeyFilterSuggestions(boolean validateOnly, boolean repeat) { + if (suggestionOps.isEmpty()) { + return false; + } + + if (validateOnly) { + return true; + } + + suggestionOps.cancelDelayedAccept(); + + String filter; + if (repeat && !suggestionOps.get(1).isEmpty()) { + filter = suggestionOps.get(1); + } else { + filter = suggestionOps.getCurrent(mInputMode.getSequenceLength()); + } + + if (filter.isEmpty()) { + mInputMode.reset(); + } else if (mInputMode.setWordStem(filter, repeat)) { + mInputMode.loadSuggestions(super::getSuggestions, filter); + } + + return true; + } + + + public boolean onKeyScrollSuggestion(boolean validateOnly, boolean backward) { + if (suggestionOps.isEmpty()) { + return false; + } + + if (validateOnly) { + return true; + } + + suggestionOps.cancelDelayedAccept(); + backward = isSystemRTL != backward; + suggestionOps.scrollTo(backward ? -1 : 1); + mInputMode.setWordStem(suggestionOps.getCurrent(), true); + appHacks.setComposingTextWithHighlightedStem(suggestionOps.getCurrent(), mInputMode); + return true; + } + + + public boolean onKeyNextLanguage(boolean validateOnly) { + if (mInputMode.isNumeric() || mEnabledLanguages.size() < 2) { + return false; + } + + if (validateOnly) { + return true; + } + + suggestionOps.cancelDelayedAccept(); + nextLang(); + mInputMode.changeLanguage(mLanguage); + mInputMode.clearWordStem(); + getSuggestions(); + + setStatusText(mInputMode.toString()); + renderMainView(); + forceShowWindowIfHidden(); + if (!suggestionOps.isEmpty()) { + UI.toastLanguage(this, mLanguage); + } + + if (mInputMode instanceof ModePredictive) { + DictionaryLoader.autoLoad(this, mLanguage); + } + + return true; + } + + + public boolean onKeyNextInputMode(boolean validateOnly) { + if (allowedInputModes.size() == 1) { + return false; + } + + if (validateOnly) { + return true; + } + + suggestionOps.scheduleDelayedAccept(mInputMode.getAutoAcceptTimeout()); // restart the timer + nextInputMode(); + renderMainView(); + forceShowWindowIfHidden(); + + return true; + } + + + public boolean onKeyShowSettings(boolean validateOnly) { + if (!isInputViewShown()) { + return false; + } + + if (!validateOnly) { + suggestionOps.cancelDelayedAccept(); + UI.showSettingsScreen(this); + } + + return true; + } + + + private void nextInputMode() { + if (mInputMode.isPassthrough()) { + return; + } else if (allowedInputModes.size() == 1 && allowedInputModes.contains(InputMode.MODE_123)) { + mInputMode = !mInputMode.is123() ? InputMode.getInstance(settings, mLanguage, inputType, InputMode.MODE_123) : mInputMode; + } + // when typing a word or viewing scrolling the suggestions, only change the case + else if (!suggestionOps.isEmpty()) { + nextTextCase(); + } + // make "abc" and "ABC" separate modes from user perspective + else if (mInputMode instanceof ModeABC && mLanguage.hasUpperCase() && mInputMode.getTextCase() == InputMode.CASE_LOWER) { + mInputMode.nextTextCase(); + } else { + int nextModeIndex = (allowedInputModes.indexOf(mInputMode.getId()) + 1) % allowedInputModes.size(); + mInputMode = InputMode.getInstance(settings, mLanguage, inputType, allowedInputModes.get(nextModeIndex)); + mInputMode.setTextFieldCase(textField.determineTextCase(inputType)); + mInputMode.determineNextWordTextCase(textField.getStringBeforeCursor()); + + resetKeyRepeat(); + } + + // save the settings for the next time + settings.saveInputMode(mInputMode.getId()); + settings.saveTextCase(mInputMode.getTextCase()); + + setStatusText(mInputMode.toString()); + } + + + protected void nextLang() { + // select the next language + int previous = mEnabledLanguages.indexOf(mLanguage.getId()); + int next = (previous + 1) % mEnabledLanguages.size(); + mLanguage = LanguageCollection.getLanguage(getApplicationContext(), mEnabledLanguages.get(next)); + + validateLanguages(); + + // save it for the next time + settings.saveInputLanguage(mLanguage.getId()); + } + + + private void nextTextCase() { + String currentSuggestionBefore = suggestionOps.getCurrent(); + int currentSuggestionIndex = suggestionOps.getCurrentIndex(); + + // When we are in AUTO mode and the dictionary word is in uppercase, + // the mode would switch to UPPERCASE, but visually, the word would not change. + // This is why we retry, until there is a visual change. + for (int retries = 0; retries < 2 && mInputMode.nextTextCase(); retries++) { + String currentSuggestionAfter = mInputMode.getSuggestions().size() >= suggestionOps.getCurrentIndex() ? mInputMode.getSuggestions().get(suggestionOps.getCurrentIndex()) : ""; + // If the suggestions are special characters, changing the text case means selecting the + // next character group. Hence, "before" and "after" are different. Also, if the new suggestion + // list is shorter, the "before" index may be invalid, so "after" would be empty. + // In these cases, we scroll to the first one, for consistency. + if (currentSuggestionAfter.isEmpty() || !currentSuggestionBefore.equalsIgnoreCase(currentSuggestionAfter)) { + currentSuggestionIndex = 0; + break; + } + + // the suggestion list is the same and the text case is different, so let's use it + if (!currentSuggestionBefore.equals(currentSuggestionAfter)) { + break; + } + } + + suggestionOps.set(mInputMode.getSuggestions(), currentSuggestionIndex); + textField.setComposingText(suggestionOps.getCurrent()); + } +} diff --git a/app/src/main/java/io/github/sspanak/tt9/ime/KeyPadHandler.java b/app/src/main/java/io/github/sspanak/tt9/ime/KeyPadHandler.java index 11a1a8e9..c587c935 100644 --- a/app/src/main/java/io/github/sspanak/tt9/ime/KeyPadHandler.java +++ b/app/src/main/java/io/github/sspanak/tt9/ime/KeyPadHandler.java @@ -1,19 +1,17 @@ package io.github.sspanak.tt9.ime; -import android.inputmethodservice.InputMethodService; import android.view.KeyEvent; import android.view.View; import android.view.inputmethod.EditorInfo; -import android.view.inputmethod.InputConnection; -import io.github.sspanak.tt9.util.Logger; -import io.github.sspanak.tt9.util.Timer; import io.github.sspanak.tt9.ime.helpers.Key; import io.github.sspanak.tt9.preferences.SettingsStore; import io.github.sspanak.tt9.preferences.screens.debug.ItemInputHandlingMode; +import io.github.sspanak.tt9.util.Logger; +import io.github.sspanak.tt9.util.Timer; -abstract class KeyPadHandler extends InputMethodService { +abstract class KeyPadHandler extends AbstractHandler { protected SettingsStore settings; // debounce handling @@ -66,7 +64,7 @@ abstract class KeyPadHandler extends InputMethodService { */ @Override public View onCreateInputView() { - return createSoftKeyView(); + return createMainView(); } @@ -105,7 +103,6 @@ abstract class KeyPadHandler extends InputMethodService { @Override public void onFinishInput() { super.onFinishInput(); - // Logger.d("onFinishInput", "When is this called?"); onStop(); } @@ -256,43 +253,7 @@ abstract class KeyPadHandler extends InputMethodService { private boolean handleHotkey(int keyCode, boolean hold, boolean repeat, boolean validateOnly) { - if (keyCode == settings.getKeyAddWord() * (hold ? -1 : 1)) { - return onKeyAddWord(validateOnly); - } - - if (keyCode == settings.getKeyChangeKeyboard() * (hold ? -1 : 1)) { - return onKeyChangeKeyboard(validateOnly); - } - - if (keyCode == settings.getKeyFilterClear() * (hold ? -1 : 1)) { - return onKeyFilterClear(validateOnly); - } - - if (keyCode == settings.getKeyFilterSuggestions() * (hold ? -1 : 1)) { - return onKeyFilterSuggestions(validateOnly, repeat); - } - - if (keyCode == settings.getKeyNextLanguage() * (hold ? -1 : 1)) { - return onKeyNextLanguage(validateOnly); - } - - if (keyCode == settings.getKeyNextInputMode() * (hold ? -1 : 1)) { - return onKeyNextInputMode(validateOnly); - } - - if (keyCode == settings.getKeyPreviousSuggestion() * (hold ? -1 : 1)) { - return onKeyScrollSuggestion(validateOnly, true); - } - - if (keyCode == settings.getKeyNextSuggestion() * (hold ? -1 : 1)) { - return onKeyScrollSuggestion(validateOnly, false); - } - - if (keyCode == settings.getKeyShowSettings() * (hold ? -1 : 1)) { - return onKeyShowSettings(validateOnly); - } - - return false; + return onHotkey(keyCode * (hold ? -1 : 1), repeat, validateOnly); } @@ -321,34 +282,4 @@ abstract class KeyPadHandler extends InputMethodService { return false; } - - - // hardware key handlers - abstract protected boolean onBack(); - abstract public boolean onBackspace(); - abstract protected boolean onNumber(int key, boolean hold, int repeat); - abstract public boolean onOK(); - abstract public boolean onText(String text, boolean validateOnly); // used for "#", "*" and whatnot - - // hotkey handlers - abstract protected boolean onKeyAddWord(boolean validateOnly); - abstract protected boolean onKeyChangeKeyboard(boolean validateOnly); - abstract protected boolean onKeyFilterClear(boolean validateOnly); - abstract protected boolean onKeyFilterSuggestions(boolean validateOnly, boolean repeat); - abstract protected boolean onKeyNextLanguage(boolean validateOnly); - abstract protected boolean onKeyNextInputMode(boolean validateOnly); - abstract protected boolean onKeyScrollSuggestion(boolean validateOnly, boolean backward); - abstract protected boolean onKeyShowSettings(boolean validateOnly); - - // helpers - abstract protected void onInit(); - abstract protected void onStart(InputConnection inputConnection, EditorInfo inputField); - abstract protected void onFinishTyping(); - abstract protected void onStop(); - abstract protected void setInputField(InputConnection inputConnection, EditorInfo inputField); - - // UI - abstract protected View createSoftKeyView(); - abstract protected boolean shouldBeVisible(); - abstract protected boolean shouldBeOff(); } diff --git a/app/src/main/java/io/github/sspanak/tt9/ime/SuggestionOps.java b/app/src/main/java/io/github/sspanak/tt9/ime/SuggestionOps.java new file mode 100644 index 00000000..90ea4b02 --- /dev/null +++ b/app/src/main/java/io/github/sspanak/tt9/ime/SuggestionOps.java @@ -0,0 +1,155 @@ +package io.github.sspanak.tt9.ime; + +import android.os.Handler; +import android.os.Looper; +import android.view.View; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; + +import io.github.sspanak.tt9.ime.helpers.TextField; +import io.github.sspanak.tt9.ui.tray.SuggestionsBar; +import io.github.sspanak.tt9.util.ConsumerCompat; + +public class SuggestionOps { + @NonNull private final Handler delayedAcceptHandler; + @NonNull private final ConsumerCompat onDelayedAccept; + @NonNull protected final SuggestionsBar suggestionBar; + @NonNull private TextField textField; + + + SuggestionOps(@NonNull TypingHandler tt9, View mainView, @NonNull ConsumerCompat onDelayedAccept) { + delayedAcceptHandler = new Handler(Looper.getMainLooper()); + this.onDelayedAccept = onDelayedAccept; + + suggestionBar = new SuggestionsBar(tt9, mainView); + textField = new TextField(null, null); + } + + + void setTextField(@NonNull TextField textField) { + this.textField = textField; + } + + + boolean isEmpty() { + return suggestionBar.isEmpty(); + } + + + String get(int index) { + return suggestionBar.getSuggestion(index); + } + + + void clear() { + set(null); + textField.setComposingText(""); + textField.finishComposingText(); + } + + + void set(ArrayList suggestions) { + suggestionBar.setSuggestions(suggestions, 0); + } + + + void set(ArrayList suggestions, int selectIndex) { + suggestionBar.setSuggestions(suggestions, selectIndex); + } + + + void scrollTo(int index) { + suggestionBar.scrollToSuggestion(index); + } + + + String acceptCurrent() { + String word = getCurrent(); + if (!word.isEmpty()) { + commitCurrent(true); + } + + return word; + } + + + String acceptIncomplete() { + String currentWord = this.getCurrent(); + commitCurrent(false); + + return currentWord; + } + + + String acceptPrevious(int sequenceLength) { + String lastComposingText = getCurrent(sequenceLength - 1); + commitCurrent(false); + return lastComposingText; + } + + + void commitCurrent(boolean entireSuggestion) { + if (!suggestionBar.isEmpty()) { + if (entireSuggestion) { + textField.setComposingText(getCurrent()); + } + textField.finishComposingText(); + } + + set(null); + } + + + int getCurrentIndex() { + return suggestionBar.getCurrentIndex(); + } + + + String getCurrent() { + return get(suggestionBar.getCurrentIndex()); + } + + + protected String getCurrent(int maxLength) { + if (maxLength == 0 || suggestionBar.isEmpty()) { + return ""; + } + + String text = getCurrent(); + if (maxLength > 0 && !text.isEmpty() && text.length() > maxLength) { + text = text.substring(0, maxLength); + } + + return text; + } + + + boolean scheduleDelayedAccept(int delay) { + cancelDelayedAccept(); + + if (suggestionBar.isEmpty()) { + return false; + } + + if (delay == 0) { + onDelayedAccept.accept(acceptCurrent()); + return true; + } else if (delay > 0) { + delayedAcceptHandler.postDelayed(() -> onDelayedAccept.accept(acceptCurrent()), delay); + } + + return false; + } + + + void cancelDelayedAccept() { + delayedAcceptHandler.removeCallbacksAndMessages(null); + } + + + void setDarkTheme(boolean yes) { + suggestionBar.setDarkTheme(yes); + } +} diff --git a/app/src/main/java/io/github/sspanak/tt9/ime/TraditionalT9.java b/app/src/main/java/io/github/sspanak/tt9/ime/TraditionalT9.java index f02287d7..3e433429 100644 --- a/app/src/main/java/io/github/sspanak/tt9/ime/TraditionalT9.java +++ b/app/src/main/java/io/github/sspanak/tt9/ime/TraditionalT9.java @@ -1,11 +1,9 @@ package io.github.sspanak.tt9.ime; -import android.content.Context; import android.content.Intent; import android.os.Build; import android.os.Handler; import android.os.Looper; -import android.view.KeyEvent; import android.view.View; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; @@ -13,130 +11,21 @@ import android.view.inputmethod.InputMethodManager; import androidx.annotation.NonNull; -import java.util.ArrayList; -import java.util.List; - -import io.github.sspanak.tt9.R; import io.github.sspanak.tt9.db.DictionaryLoader; import io.github.sspanak.tt9.db.WordStoreAsync; -import io.github.sspanak.tt9.ime.helpers.AppHacks; -import io.github.sspanak.tt9.ime.helpers.InputModeValidator; -import io.github.sspanak.tt9.ime.helpers.InputType; -import io.github.sspanak.tt9.ime.helpers.TextField; -import io.github.sspanak.tt9.ime.modes.InputMode; -import io.github.sspanak.tt9.ime.modes.ModeABC; import io.github.sspanak.tt9.ime.modes.ModePassthrough; -import io.github.sspanak.tt9.ime.modes.ModePredictive; -import io.github.sspanak.tt9.languages.Language; -import io.github.sspanak.tt9.languages.LanguageCollection; -import io.github.sspanak.tt9.languages.LanguageKind; import io.github.sspanak.tt9.preferences.SettingsStore; -import io.github.sspanak.tt9.preferences.helpers.Hotkeys; import io.github.sspanak.tt9.ui.PopupDialogActivity; import io.github.sspanak.tt9.ui.UI; import io.github.sspanak.tt9.ui.main.MainView; import io.github.sspanak.tt9.ui.tray.StatusBar; -import io.github.sspanak.tt9.ui.tray.SuggestionsBar; import io.github.sspanak.tt9.util.Logger; -import io.github.sspanak.tt9.util.Text; -public class TraditionalT9 extends KeyPadHandler { - private InputConnection currentInputConnection = null; - // internal settings/data - @NonNull private AppHacks appHacks = new AppHacks(null,null, null, null); - @NonNull private TextField textField = new TextField(null, null); - @NonNull private InputType inputType = new InputType(null, null); - @NonNull private final Handler autoAcceptHandler = new Handler(Looper.getMainLooper()); - @NonNull private final Handler normalizationHandler = new Handler(Looper.getMainLooper()); - - // input mode - private ArrayList allowedInputModes = new ArrayList<>(); - @NonNull private InputMode mInputMode = InputMode.getInstance(null, null, null, InputMode.MODE_PASSTHROUGH); - - // language - protected ArrayList mEnabledLanguages; - protected Language mLanguage; - protected Language systemLanguage; - - // soft key view +public class TraditionalT9 extends HotkeyHandler { + @NonNull + private final Handler normalizationHandler = new Handler(Looper.getMainLooper()); private MainView mainView = null; private StatusBar statusBar = null; - private SuggestionsBar suggestionBar = null; - - private static TraditionalT9 self; - public static Context getMainContext() { - return self.getApplicationContext(); - } - - public SettingsStore getSettings() { - return settings; - } - - public boolean isInputModeNumeric() { - return mInputMode.is123(); - } - - public boolean isNumericModeStrict() { - return mInputMode.is123() && inputType.isNumeric() && !inputType.isPhoneNumber(); - } - - public boolean isNumericModeSigned() { - return mInputMode.is123() && inputType.isSignedNumber(); - } - - public boolean isInputModePhone() { - return mInputMode.is123() && inputType.isPhoneNumber(); - } - - public int getTextCase() { - return mInputMode.getTextCase(); - } - - - private void validateLanguages() { - mEnabledLanguages = InputModeValidator.validateEnabledLanguages(getMainContext(), mEnabledLanguages); - mLanguage = InputModeValidator.validateLanguage(getMainContext(), mLanguage, mEnabledLanguages); - - settings.saveEnabledLanguageIds(mEnabledLanguages); - settings.saveInputLanguage(mLanguage.getId()); - } - - - private void validateFunctionKeys() { - if (settings.areHotkeysInitialized()) { - Hotkeys.setDefault(settings); - } - } - - - /** - * getInputMode - * Load the last input mode or choose a more appropriate one. - * Some input fields support only numbers or are not suited for predictions (e.g. password fields) - */ - private InputMode getInputMode() { - if (!inputType.isValid() || (inputType.isLimited() && !appHacks.isTermux())) { - return InputMode.getInstance(settings, mLanguage, inputType, InputMode.MODE_PASSTHROUGH); - } - - allowedInputModes = textField.determineInputModes(inputType); - int validModeId = InputModeValidator.validateMode(settings.getInputMode(), allowedInputModes); - return InputMode.getInstance(settings, mLanguage, inputType, validModeId); - } - - - /** - * determineTextCase - * Restore the last text case or auto-select a new one. If the InputMode supports it, it can change - * the text case based on grammar rules, otherwise we fallback to the input field properties or the - * last saved mode. - */ - private void determineTextCase() { - mInputMode.defaultTextCase(); - mInputMode.setTextFieldCase(textField.determineTextCase(inputType)); - mInputMode.determineNextWordTextCase(textField.getStringBeforeCursor()); - InputModeValidator.validateTextCase(mInputMode, settings.getTextCase()); - } @Override @@ -147,7 +36,7 @@ public class TraditionalT9 extends KeyPadHandler { if (message != null) { forceShowWindowIfHidden(); if (!message.isEmpty()) { - UI.toastLong(self, message); + UI.toastLong(this, message); } } @@ -155,8 +44,8 @@ public class TraditionalT9 extends KeyPadHandler { } + @Override protected void onInit() { - self = this; Logger.setLevel(settings.getLogLevel()); WordStoreAsync.init(this); @@ -166,42 +55,21 @@ public class TraditionalT9 extends KeyPadHandler { initTray(); } - validateFunctionKeys(); - } - - - protected void setInputField(InputConnection connection, EditorInfo field) { - currentInputConnection = connection; - inputType = new InputType(currentInputConnection, field); - textField = new TextField(currentInputConnection, field); - appHacks = new AppHacks(settings, connection, field, textField); - } - - - private void initTyping() { - // in case we are back from Settings screen, update the language list - mEnabledLanguages = settings.getEnabledLanguageIds(); - mLanguage = LanguageCollection.getLanguage(getMainContext(), settings.getInputLanguage()); - validateLanguages(); - - resetKeyRepeat(); - setSuggestions(null); - mInputMode = getInputMode(); - determineTextCase(); + super.onInit(); } private void initTray() { setInputView(mainView.getView()); + createSuggestionBar(mainView.getView()); statusBar = new StatusBar(mainView.getView()); - suggestionBar = new SuggestionsBar(this, mainView.getView()); } private void setDarkTheme() { mainView.setDarkTheme(settings.getDarkTheme()); statusBar.setDarkTheme(settings.getDarkTheme()); - suggestionBar.setDarkTheme(settings.getDarkTheme()); + suggestionOps.setDarkTheme(settings.getDarkTheme()); } @@ -209,17 +77,17 @@ public class TraditionalT9 extends KeyPadHandler { if (mainView.createView()) { initTray(); } - statusBar.setText(mInputMode.toString()); + setStatusText(mInputMode.toString()); setDarkTheme(); mainView.render(); } + @Override protected void onStart(InputConnection connection, EditorInfo field) { Logger.setLevel(settings.getLogLevel()); - setInputField(connection, field); - initTyping(); + super.onStart(connection, field); if (mInputMode.isPassthrough()) { // When the input is invalid or simple, let Android handle it. @@ -229,25 +97,25 @@ public class TraditionalT9 extends KeyPadHandler { } normalizationHandler.removeCallbacksAndMessages(null); - systemLanguage = LanguageCollection.getDefault(this); initUi(); updateInputViewShown(); } + @Override protected void onFinishTyping() { - cancelAutoAccept(); if (!(mInputMode instanceof ModePassthrough)) { DictionaryLoader.autoLoad(this, mLanguage); } - mInputMode = InputMode.getInstance(null, null, null, InputMode.MODE_PASSTHROUGH); + super.onFinishTyping(); } + @Override protected void onStop() { onFinishTyping(); - clearSuggestions(); + suggestionOps.clear(); statusBar.setText("--"); normalizationHandler.removeCallbacksAndMessages(null); @@ -258,566 +126,11 @@ public class TraditionalT9 extends KeyPadHandler { } - public boolean onBack() { - return settings.getShowSoftNumpad(); - } - - - public boolean onBackspace() { - // 1. Dialer fields seem to handle backspace on their own and we must ignore it, - // otherwise, keyDown race condition occur for all keys. - // 2. Allow the assigned key to function normally, when there is no text (e.g. "Back" navigates back) - // 3. Some app may need special treatment, so let it be. - if (mInputMode.isPassthrough() || !(textField.isThereText() || appHacks.onBackspace(mInputMode))) { - Logger.d("onBackspace", "backspace ignored"); - mInputMode.reset(); - return false; - } - - cancelAutoAccept(); - resetKeyRepeat(); - - if (mInputMode.onBackspace()) { - getSuggestions(); - } else { - commitCurrentSuggestion(false); - super.sendDownUpKeyEvents(KeyEvent.KEYCODE_DEL); - } - - Logger.d("onBackspace", "backspace handled"); - return true; - } - - /** - * onNumber - * - * @param key Must be a number from 1 to 9, not a "KeyEvent.KEYCODE_X" - * @param hold If "true" we are calling the handler, because the key is being held. - * @param repeat If "true" we are calling the handler, because the key was pressed more than once - * @return boolean - */ - protected boolean onNumber(int key, boolean hold, int repeat) { - cancelAutoAccept(); - forceShowWindowIfHidden(); - - // Automatically accept the previous word, when the next one is a space or punctuation, - // instead of requiring "OK" before that. - // First pass, analyze the incoming key press and decide whether it could be the start of - // a new word. - if (mInputMode.shouldAcceptPreviousSuggestion(key)) { - autoCorrectSpace(acceptIncompleteSuggestion(), false, key); - } - - // Auto-adjust the text case before each word, if the InputMode supports it. - if (getComposingText().isEmpty()) { - mInputMode.determineNextWordTextCase(textField.getStringBeforeCursor()); - } - - if (!mInputMode.onNumber(key, hold, repeat)) { - return false; - } - - if (mInputMode.shouldSelectNextSuggestion() && !isSuggestionViewHidden()) { - onKeyScrollSuggestion(false, false); - scheduleAutoAccept(mInputMode.getAutoAcceptTimeout()); - } else { - getSuggestions(); - } - - return true; - } - - - public boolean onOK() { - cancelAutoAccept(); - - if (isSuggestionViewHidden()) { - int action = textField.getAction(); - return action == TextField.IME_ACTION_ENTER ? appHacks.onEnter() : textField.performAction(action); - } - - acceptCurrentSuggestion(KeyEvent.KEYCODE_ENTER); - return true; - } - - - public boolean onText(String text) { return onText(text, false); } - - public boolean onText(String text, boolean validateOnly) { - if (mInputMode.shouldIgnoreText(text)) { - return false; - } - - if (validateOnly) { - return true; - } - - cancelAutoAccept(); - forceShowWindowIfHidden(); - - // accept the previously typed word (if any) - autoCorrectSpace(acceptIncompleteSuggestion(), false, -1); - - // "type" and accept the new word - mInputMode.onAcceptSuggestion(text); - textField.setText(text); - autoCorrectSpace(text, true, -1); - - return true; - } - - - public boolean onKeyAddWord(boolean validateOnly) { - if (!isInputViewShown() || mInputMode.isNumeric()) { - return false; - } - - if (validateOnly) { - return true; - } - - if (DictionaryLoader.getInstance(this).isRunning()) { - UI.toast(this, R.string.dictionary_loading_please_wait); - return true; - } - - cancelAutoAccept(); - acceptIncompleteSuggestion(); - - String word = textField.getSurroundingWord(mLanguage); - if (word.isEmpty()) { - UI.toastLong(this, R.string.add_word_no_selection); - } else { - UI.showAddWordDialog(this, mLanguage.getId(), word); - } - - return true; - } - - - public boolean onKeyChangeKeyboard(boolean validateOnly) { - if (!isInputViewShown()) { - return false; - } - - if (!validateOnly) { - UI.showChangeKeyboardDialog(this); - } - - return true; - } - - - public boolean onKeyFilterClear(boolean validateOnly) { - if (isSuggestionViewHidden()) { - return false; - } - - if (validateOnly) { - return true; - } - - cancelAutoAccept(); - - if (mInputMode.clearWordStem()) { - mInputMode.loadSuggestions(this::getSuggestions, getComposingText()); - return true; - } - - acceptIncompleteSuggestion(); - resetKeyRepeat(); - - return true; - } - - - public boolean onKeyFilterSuggestions(boolean validateOnly, boolean repeat) { - if (isSuggestionViewHidden()) { - return false; - } - - if (validateOnly) { - return true; - } - - cancelAutoAccept(); - - String filter; - if (repeat && !suggestionBar.getSuggestion(1).isEmpty()) { - filter = suggestionBar.getSuggestion(1); - } else { - filter = getComposingText(); - } - - if (filter.isEmpty()) { - mInputMode.reset(); - } else if (mInputMode.setWordStem(filter, repeat)) { - mInputMode.loadSuggestions(this::getSuggestions, filter); - } - - return true; - } - - - public boolean onKeyScrollSuggestion(boolean validateOnly, boolean backward) { - if (isSuggestionViewHidden()) { - return false; - } - - if (validateOnly) { - return true; - } - - cancelAutoAccept(); - backward = LanguageKind.isRTL(systemLanguage) != backward; - suggestionBar.scrollToSuggestion(backward ? -1 : 1); - mInputMode.setWordStem(suggestionBar.getCurrentSuggestion(), true); - setComposingTextWithHighlightedStem(suggestionBar.getCurrentSuggestion()); - return true; - } - - - public boolean onKeyNextLanguage(boolean validateOnly) { - if (mInputMode.isNumeric() || mEnabledLanguages.size() < 2) { - return false; - } - - if (validateOnly) { - return true; - } - - cancelAutoAccept(); - nextLang(); - mInputMode.changeLanguage(mLanguage); - mInputMode.clearWordStem(); - getSuggestions(); - - statusBar.setText(mInputMode.toString()); - mainView.render(); - forceShowWindowIfHidden(); - if (!suggestionBar.isEmpty()) { - UI.toastLanguage(this, mLanguage); - } - - if (mInputMode instanceof ModePredictive) { - DictionaryLoader.autoLoad(this, mLanguage); - } - - return true; - } - - - public boolean onKeyNextInputMode(boolean validateOnly) { - if (allowedInputModes.size() == 1) { - return false; - } - - if (validateOnly) { - return true; - } - - scheduleAutoAccept(mInputMode.getAutoAcceptTimeout()); // restart the timer - nextInputMode(); - mainView.render(); - forceShowWindowIfHidden(); - - return true; - } - - - public boolean onKeyShowSettings(boolean validateOnly) { - if (!isInputViewShown()) { - return false; - } - - if (validateOnly) { - return true; - } - - cancelAutoAccept(); - UI.showSettingsScreen(this); - return true; - } - - - @Override - public void onUpdateSelection(int oldSelStart, int oldSelEnd, int newSelStart, int newSelEnd, int candidatesStart, int candidatesEnd) { - // Logger.d("onUpdateSelection", "oldSelStart: " + oldSelStart + " oldSelEnd: " + oldSelEnd + " newSelStart: " + newSelStart + " oldSelEnd: " + oldSelEnd + " candidatesStart: " + candidatesStart + " candidatesEnd: " + candidatesEnd); - - super.onUpdateSelection(oldSelStart, oldSelEnd, newSelStart, newSelEnd, candidatesStart, candidatesEnd); - - // If the cursor moves while composing a word (usually, because the user has touched the screen outside the word), we must - // end typing end accept the word. Otherwise, the cursor would jump back at the end of the word, after the next key press. - // This is confusing from user perspective, so we want to avoid it. - if ( - candidatesStart != -1 && candidatesEnd != -1 - && (newSelStart != candidatesEnd || newSelEnd != candidatesEnd) - && !suggestionBar.isEmpty() - ) { - acceptIncompleteSuggestion(); - } - } - - - private boolean isSuggestionViewHidden() { - return suggestionBar == null || suggestionBar.isEmpty(); - } - - - private boolean scheduleAutoAccept(int delay) { - cancelAutoAccept(); - - if (suggestionBar.isEmpty()) { - return false; - } - - if (delay == 0) { - this.acceptCurrentSuggestion(); - return true; - } else if (delay > 0) { - autoAcceptHandler.postDelayed(this::acceptCurrentSuggestion, delay); - } - - return false; - } - - - private void cancelAutoAccept() { - autoAcceptHandler.removeCallbacksAndMessages(null); - } - - - private void acceptCurrentSuggestion(int fromKey) { - String word = suggestionBar.getCurrentSuggestion(); - if (word.isEmpty()) { - return; - } - - mInputMode.onAcceptSuggestion(word); - commitCurrentSuggestion(); - autoCorrectSpace(word, true, fromKey); - resetKeyRepeat(); - } - - private void acceptCurrentSuggestion() { - acceptCurrentSuggestion(-1); - } - - - private String acceptIncompleteSuggestion() { - String currentWord = getComposingText(); - mInputMode.onAcceptSuggestion(currentWord); - commitCurrentSuggestion(false); - - return currentWord; - } - - - private void commitCurrentSuggestion() { - commitCurrentSuggestion(true); - } - - private void commitCurrentSuggestion(boolean entireSuggestion) { - if (!isSuggestionViewHidden()) { - if (entireSuggestion) { - textField.setComposingText(suggestionBar.getCurrentSuggestion()); - } - textField.finishComposingText(); - } - - setSuggestions(null); - } - - - private void clearSuggestions() { - setSuggestions(null); - textField.setComposingText(""); - textField.finishComposingText(); - } - - - private void getSuggestions() { - if (mInputMode instanceof ModePredictive && DictionaryLoader.getInstance(this).isRunning()) { - UI.toast(this, R.string.dictionary_loading_please_wait); - } else { - mInputMode.loadSuggestions(this::handleSuggestions, suggestionBar.getCurrentSuggestion()); - } - } - - - private void handleSuggestions() { - // Automatically accept the previous word, without requiring OK. This is similar to what - // Second pass, analyze the available suggestions and decide if combining them with the - // last key press makes up a compound word like: (it)'s, (I)'ve, l'(oiseau), or it is - // just the end of a sentence, like: "word." or "another?" - if (mInputMode.shouldAcceptPreviousSuggestion()) { - String lastComposingText = getComposingText(mInputMode.getSequenceLength() - 1); - commitCurrentSuggestion(false); - mInputMode.onAcceptSuggestion(lastComposingText, true); - autoCorrectSpace(lastComposingText, false, -1); - mInputMode.determineNextWordTextCase(textField.getStringBeforeCursor()); - } - - // display the word suggestions - setSuggestions(mInputMode.getSuggestions()); - - // In case we are here, because the language was changed, and there were words for the old language, - // but there are no words for the new language, we'll get only generated suggestions, consisting - // of the last word of the previous language + endings from the new language. These words are invalid, - // so we discard them. - if (mInputMode instanceof ModePredictive && !mLanguage.isValidWord(suggestionBar.getCurrentSuggestion()) && !Text.isGraphic(suggestionBar.getCurrentSuggestion())) { - mInputMode.reset(); - setSuggestions(null); - } - - // flush the first suggestion, if the InputMode has requested it - if (scheduleAutoAccept(mInputMode.getAutoAcceptTimeout())) { - return; - } - - // Otherwise, 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 = suggestionBar.getCurrentSuggestion(); - word = word.substring(0, Math.min(mInputMode.getSequenceLength(), word.length())); - setComposingTextWithHighlightedStem(word); - } - - - private void setSuggestions(List suggestions) { - setSuggestions(suggestions, 0); - } - - private void setSuggestions(List suggestions, int selectedIndex) { - if (suggestionBar != null) { - suggestionBar.setSuggestions(suggestions, selectedIndex); - } - } - - - private String getComposingText(int maxLength) { - if (maxLength == 0 || suggestionBar.isEmpty()) { - return ""; - } - - maxLength = maxLength > 0 ? Math.min(maxLength, mInputMode.getSequenceLength()) : mInputMode.getSequenceLength(); - - String text = suggestionBar.getCurrentSuggestion(); - if (!text.isEmpty() && text.length() > maxLength) { - text = text.substring(0, maxLength); - } - - return text; - } - - - private String getComposingText() { - return getComposingText(-1); - } - - - - private void setComposingTextWithHighlightedStem(@NonNull String word) { - if (appHacks.setComposingTextWithHighlightedStem(word)) { - Logger.w("highlightComposingText", "Defective text field detected! Text highlighting disabled."); - } else if (word.isEmpty() || !Character.isLetterOrDigit(word.charAt(0))) { - // Leave emoji and special characters alone. Adding bold or italic breaks them. - textField.setComposingText(word); - } else { - textField.setComposingTextWithHighlightedStem(word, mInputMode); - } - } - - - private void nextInputMode() { - if (mInputMode.isPassthrough()) { - return; - } else if (allowedInputModes.size() == 1 && allowedInputModes.contains(InputMode.MODE_123)) { - mInputMode = !mInputMode.is123() ? InputMode.getInstance(settings, mLanguage, inputType, InputMode.MODE_123) : mInputMode; - } - // when typing a word or viewing scrolling the suggestions, only change the case - else if (!isSuggestionViewHidden()) { - nextTextCase(); - } - // make "abc" and "ABC" separate modes from user perspective - else if (mInputMode instanceof ModeABC && mLanguage.hasUpperCase() && mInputMode.getTextCase() == InputMode.CASE_LOWER) { - mInputMode.nextTextCase(); - } else { - int nextModeIndex = (allowedInputModes.indexOf(mInputMode.getId()) + 1) % allowedInputModes.size(); - mInputMode = InputMode.getInstance(settings, mLanguage, inputType, allowedInputModes.get(nextModeIndex)); - mInputMode.setTextFieldCase(textField.determineTextCase(inputType)); - mInputMode.determineNextWordTextCase(textField.getStringBeforeCursor()); - - resetKeyRepeat(); - } - - // save the settings for the next time - settings.saveInputMode(mInputMode.getId()); - settings.saveTextCase(mInputMode.getTextCase()); - - statusBar.setText(mInputMode.toString()); - } - - - private void nextLang() { - // select the next language - int previous = mEnabledLanguages.indexOf(mLanguage.getId()); - int next = (previous + 1) % mEnabledLanguages.size(); - mLanguage = LanguageCollection.getLanguage(getMainContext(), mEnabledLanguages.get(next)); - - validateLanguages(); - - // save it for the next time - settings.saveInputLanguage(mLanguage.getId()); - } - - - private void nextTextCase() { - String currentSuggestionBefore = getComposingText(); - int currentSuggestionIndex = suggestionBar.getCurrentIndex(); - - // When we are in AUTO mode and the dictionary word is in uppercase, - // the mode would switch to UPPERCASE, but visually, the word would not change. - // This is why we retry, until there is a visual change. - for (int retries = 0; retries < 2 && mInputMode.nextTextCase(); retries++) { - String currentSuggestionAfter = mInputMode.getSuggestions().size() >= suggestionBar.getCurrentIndex() ? mInputMode.getSuggestions().get(suggestionBar.getCurrentIndex()) : ""; - // If the suggestions are special characters, changing the text case means selecting the - // next character group. Hence, "before" and "after" are different. Also, if the new suggestion - // list is shorter, the "before" index may be invalid, so "after" would be empty. - // In these cases, we scroll to the first one, for consistency. - if (currentSuggestionAfter.isEmpty() || !currentSuggestionBefore.equalsIgnoreCase(currentSuggestionAfter)) { - currentSuggestionIndex = 0; - break; - } - - // the suggestion list is the same and the text case is different, so let's use it - if (!currentSuggestionBefore.equals(currentSuggestionAfter)) { - break; - } - } - - setSuggestions(mInputMode.getSuggestions(), currentSuggestionIndex); - textField.setComposingText(suggestionBar.getCurrentSuggestion()); - } - - - private void autoCorrectSpace(String currentWord, boolean isWordAcceptedManually, int nextKey) { - if (mInputMode.shouldDeletePrecedingSpace(inputType)) { - textField.deletePrecedingSpace(currentWord); - } - - if (mInputMode.shouldAddAutoSpace(inputType, textField, isWordAcceptedManually, nextKey)) { - textField.setText(" "); - } - } - - - /** - * createSoftKeyView + * createMainView * Generates the actual UI of TT9. */ - protected View createSoftKeyView() { + protected View createMainView() { mainView.forceCreateView(); initTray(); setDarkTheme(); @@ -825,6 +138,15 @@ public class TraditionalT9 extends KeyPadHandler { } + /** + * Populates the UI elements with strings and icons + */ + @Override + protected void renderMainView() { + mainView.render(); + } + + /** * forceShowWindowIfHidden * Some applications may hide our window and it remains invisible until the screen is touched or OK is pressed. @@ -844,6 +166,12 @@ public class TraditionalT9 extends KeyPadHandler { } + @Override + protected void setStatusText(String status) { + statusBar.setText(status); + } + + @Override protected boolean shouldBeVisible() { return !getInputMode().isPassthrough(); @@ -854,4 +182,31 @@ public class TraditionalT9 extends KeyPadHandler { protected boolean shouldBeOff() { return currentInputConnection == null || mInputMode.isPassthrough(); } + + + /**** Informational methods for the on-screen keyboard ****/ + + public int getTextCase() { + return mInputMode.getTextCase(); + } + + public boolean isInputModeNumeric() { + return mInputMode.is123(); + } + + public boolean isNumericModeStrict() { + return mInputMode.is123() && inputType.isNumeric() && !inputType.isPhoneNumber(); + } + + public boolean isNumericModeSigned() { + return mInputMode.is123() && inputType.isSignedNumber(); + } + + public boolean isInputModePhone() { + return mInputMode.is123() && inputType.isPhoneNumber(); + } + + public SettingsStore getSettings() { + return settings; + } } diff --git a/app/src/main/java/io/github/sspanak/tt9/ime/TypingHandler.java b/app/src/main/java/io/github/sspanak/tt9/ime/TypingHandler.java new file mode 100644 index 00000000..6a65d40c --- /dev/null +++ b/app/src/main/java/io/github/sspanak/tt9/ime/TypingHandler.java @@ -0,0 +1,305 @@ +package io.github.sspanak.tt9.ime; + +import android.view.KeyEvent; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; + +import io.github.sspanak.tt9.R; +import io.github.sspanak.tt9.db.DictionaryLoader; +import io.github.sspanak.tt9.ime.helpers.AppHacks; +import io.github.sspanak.tt9.ime.helpers.InputModeValidator; +import io.github.sspanak.tt9.ime.helpers.InputType; +import io.github.sspanak.tt9.ime.helpers.TextField; +import io.github.sspanak.tt9.ime.modes.InputMode; +import io.github.sspanak.tt9.ime.modes.ModePredictive; +import io.github.sspanak.tt9.languages.Language; +import io.github.sspanak.tt9.languages.LanguageCollection; +import io.github.sspanak.tt9.ui.UI; +import io.github.sspanak.tt9.util.Logger; +import io.github.sspanak.tt9.util.Text; + +public abstract class TypingHandler extends KeyPadHandler { + // internal settings/data + @NonNull protected AppHacks appHacks = new AppHacks(null,null, null, null); + protected InputConnection currentInputConnection = null; + @NonNull protected InputType inputType = new InputType(null, null); + @NonNull protected TextField textField = new TextField(null, null); + protected SuggestionOps suggestionOps; + + // input + protected ArrayList allowedInputModes = new ArrayList<>(); + @NonNull + protected InputMode mInputMode = InputMode.getInstance(null, null, null, InputMode.MODE_PASSTHROUGH); + + // language + protected ArrayList mEnabledLanguages; + protected Language mLanguage; + + @Override + protected void createSuggestionBar(View mainView) { + suggestionOps = new SuggestionOps(this, mainView, this::onAcceptSuggestionsDelayed); + } + + @Override + protected void onStart(InputConnection connection, EditorInfo field) { + setInputField(connection, field); + + // in case we are back from Settings screen, update the language list + mEnabledLanguages = settings.getEnabledLanguageIds(); + mLanguage = LanguageCollection.getLanguage(getApplicationContext(), settings.getInputLanguage()); + validateLanguages(); + + resetKeyRepeat(); + mInputMode = getInputMode(); + determineTextCase(); + + suggestionOps.setTextField(textField); + suggestionOps.set(null); + + appHacks = new AppHacks(settings, connection, field, textField); + } + + + protected void setInputField(InputConnection connection, EditorInfo field) { + currentInputConnection = connection; + inputType = new InputType(currentInputConnection, field); + textField = new TextField(currentInputConnection, field); + } + + + protected void validateLanguages() { + mEnabledLanguages = InputModeValidator.validateEnabledLanguages(getApplicationContext(), mEnabledLanguages); + mLanguage = InputModeValidator.validateLanguage(getApplicationContext(), mLanguage, mEnabledLanguages); + + settings.saveEnabledLanguageIds(mEnabledLanguages); + settings.saveInputLanguage(mLanguage.getId()); + } + + + protected void onFinishTyping() { + suggestionOps.cancelDelayedAccept(); + mInputMode = InputMode.getInstance(null, null, null, InputMode.MODE_PASSTHROUGH); + } + + + public boolean onBackspace() { + // 1. Dialer fields seem to handle backspace on their own and we must ignore it, + // otherwise, keyDown race condition occur for all keys. + // 2. Allow the assigned key to function normally, when there is no text (e.g. "Back" navigates back) + // 3. Some app may need special treatment, so let it be. + if (mInputMode.isPassthrough() || !(textField.isThereText() || appHacks.onBackspace(mInputMode))) { + Logger.d("onBackspace", "backspace ignored"); + mInputMode.reset(); + return false; + } + + suggestionOps.cancelDelayedAccept(); + resetKeyRepeat(); + + if (mInputMode.onBackspace()) { + getSuggestions(); + } else { + suggestionOps.commitCurrent(false); + super.sendDownUpKeyEvents(KeyEvent.KEYCODE_DEL); + } + + Logger.d("onBackspace", "backspace handled"); + return true; + } + + + /** + * onNumber + * + * @param key Must be a number from 1 to 9, not a "KeyEvent.KEYCODE_X" + * @param hold If "true" we are calling the handler, because the key is being held. + * @param repeat If "true" we are calling the handler, because the key was pressed more than once + * @return boolean + */ + protected boolean onNumber(int key, boolean hold, int repeat) { + suggestionOps.cancelDelayedAccept(); + forceShowWindowIfHidden(); + + // Automatically accept the previous word, when the next one is a space or punctuation, + // instead of requiring "OK" before that. + // First pass, analyze the incoming key press and decide whether it could be the start of + // a new word. + if (mInputMode.shouldAcceptPreviousSuggestion(key)) { + String lastWord = suggestionOps.acceptIncomplete(); + mInputMode.onAcceptSuggestion(lastWord); + autoCorrectSpace(lastWord, false, key); + } + + // Auto-adjust the text case before each word, if the InputMode supports it. + if (suggestionOps.getCurrent().isEmpty()) { + mInputMode.determineNextWordTextCase(textField.getStringBeforeCursor()); + } + + if (!mInputMode.onNumber(key, hold, repeat)) { + return false; + } + + if (mInputMode.shouldSelectNextSuggestion() && !suggestionOps.isEmpty()) { + onHotkey(settings.getKeyNextSuggestion(), false, false); + suggestionOps.scheduleDelayedAccept(mInputMode.getAutoAcceptTimeout()); + } else { + getSuggestions(); + } + + return true; + } + + + public boolean onText(String text, boolean validateOnly) { + if (mInputMode.shouldIgnoreText(text)) { + return false; + } + + if (validateOnly) { + return true; + } + + suggestionOps.cancelDelayedAccept(); + forceShowWindowIfHidden(); + + // accept the previously typed word (if any) + String lastWord = suggestionOps.acceptIncomplete(); + mInputMode.onAcceptSuggestion(lastWord); + autoCorrectSpace(lastWord, false, -1); + + // "type" and accept the new word + mInputMode.onAcceptSuggestion(text); + textField.setText(text); + autoCorrectSpace(text, true, -1); + + return true; + } + + + private void autoCorrectSpace(String currentWord, boolean isWordAcceptedManually, int nextKey) { + if (mInputMode.shouldDeletePrecedingSpace(inputType)) { + textField.deletePrecedingSpace(currentWord); + } + + if (mInputMode.shouldAddAutoSpace(inputType, textField, isWordAcceptedManually, nextKey)) { + textField.setText(" "); + } + } + + + /** + * determineTextCase + * Restore the last text case or auto-select a new one. If the InputMode supports it, it can change + * the text case based on grammar rules, otherwise we fallback to the input field properties or the + * last saved mode. + */ + private void determineTextCase() { + mInputMode.defaultTextCase(); + mInputMode.setTextFieldCase(textField.determineTextCase(inputType)); + mInputMode.determineNextWordTextCase(textField.getStringBeforeCursor()); + InputModeValidator.validateTextCase(mInputMode, settings.getTextCase()); + } + + + /** + * getInputMode + * Load the last input mode or choose a more appropriate one. + * Some input fields support only numbers or are not suited for predictions (e.g. password fields) + */ + protected InputMode getInputMode() { + if (!inputType.isValid() || (inputType.isLimited() && !appHacks.isTermux())) { + return InputMode.getInstance(settings, mLanguage, inputType, InputMode.MODE_PASSTHROUGH); + } + + allowedInputModes = textField.determineInputModes(inputType); + int validModeId = InputModeValidator.validateMode(settings.getInputMode(), allowedInputModes); + return InputMode.getInstance(settings, mLanguage, inputType, validModeId); + } + + + @Override + public void onUpdateSelection(int oldSelStart, int oldSelEnd, int newSelStart, int newSelEnd, int candidatesStart, int candidatesEnd) { + // Logger.d("onUpdateSelection", "oldSelStart: " + oldSelStart + " oldSelEnd: " + oldSelEnd + " newSelStart: " + newSelStart + " oldSelEnd: " + oldSelEnd + " candidatesStart: " + candidatesStart + " candidatesEnd: " + candidatesEnd); + + super.onUpdateSelection(oldSelStart, oldSelEnd, newSelStart, newSelEnd, candidatesStart, candidatesEnd); + + // If the cursor moves while composing a word (usually, because the user has touched the screen outside the word), we must + // end typing end accept the word. Otherwise, the cursor would jump back at the end of the word, after the next key press. + // This is confusing from user perspective, so we want to avoid it. + if ( + candidatesStart != -1 && candidatesEnd != -1 + && (newSelStart != candidatesEnd || newSelEnd != candidatesEnd) + && !suggestionOps.isEmpty() + ) { + mInputMode.onAcceptSuggestion(suggestionOps.acceptIncomplete()); + } + } + + + protected void onAcceptSuggestionAutomatically(String word) { + mInputMode.onAcceptSuggestion(word, true); + autoCorrectSpace(word, false, -1); + mInputMode.determineNextWordTextCase(textField.getStringBeforeCursor()); + } + + private void onAcceptSuggestionsDelayed(String word) { + onAcceptSuggestionManually(word, -1); + } + + protected void onAcceptSuggestionManually(String word, int fromKey) { + mInputMode.onAcceptSuggestion(word); + if (!word.isEmpty()) { + autoCorrectSpace(word, true, fromKey); + resetKeyRepeat(); + } + } + + + + protected void getSuggestions() { + if (mInputMode instanceof ModePredictive && DictionaryLoader.getInstance(this).isRunning()) { + UI.toast(this, R.string.dictionary_loading_please_wait); + } else { + mInputMode.loadSuggestions(this::handleSuggestions, suggestionOps.getCurrent()); + } + } + + + protected void handleSuggestions() { + // Second pass, analyze the available suggestions and decide if combining them with the + // last key press makes up a compound word like: (it)'s, (I)'ve, l'(oiseau), or it is + // just the end of a sentence, like: "word." or "another?" + if (mInputMode.shouldAcceptPreviousSuggestion()) { + String lastWord = suggestionOps.acceptPrevious(mInputMode.getSequenceLength()); + onAcceptSuggestionAutomatically(lastWord); + } + + // display the word suggestions + suggestionOps.set(mInputMode.getSuggestions()); + + // In case we are here, because the language was changed, and there were words for the old language, + // but there are no words for the new language, we'll get only generated suggestions, consisting + // of the last word of the previous language + endings from the new language. These words are invalid, + // so we discard them. + if (mInputMode instanceof ModePredictive && !mLanguage.isValidWord(suggestionOps.getCurrent()) && !Text.isGraphic(suggestionOps.getCurrent())) { + mInputMode.reset(); + suggestionOps.set(null); + } + + // flush the first suggestion, if the InputMode has requested it + if (suggestionOps.scheduleDelayedAccept(mInputMode.getAutoAcceptTimeout())) { + return; + } + + // Otherwise, 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 trimmedWord = suggestionOps.getCurrent(mInputMode.getSequenceLength()); + appHacks.setComposingTextWithHighlightedStem(trimmedWord, mInputMode); + } +} diff --git a/app/src/main/java/io/github/sspanak/tt9/ime/helpers/AppHacks.java b/app/src/main/java/io/github/sspanak/tt9/ime/helpers/AppHacks.java index 1a582697..1999eef7 100644 --- a/app/src/main/java/io/github/sspanak/tt9/ime/helpers/AppHacks.java +++ b/app/src/main/java/io/github/sspanak/tt9/ime/helpers/AppHacks.java @@ -83,13 +83,12 @@ public class AppHacks { * setComposingTextWithHighlightedStem * A compatibility function for text fields that do not support SpannableString. Effectively disables highlighting. */ - public boolean setComposingTextWithHighlightedStem(@NonNull String word) { + public void setComposingTextWithHighlightedStem(@NonNull String word, InputMode inputMode) { if (isKindleInvertedTextField()) { textField.setComposingText(word); - return true; + } else { + textField.setComposingTextWithHighlightedStem(word, inputMode); } - - return false; } diff --git a/app/src/main/java/io/github/sspanak/tt9/ime/helpers/TextField.java b/app/src/main/java/io/github/sspanak/tt9/ime/helpers/TextField.java index 2cda7209..2e72fb4c 100644 --- a/app/src/main/java/io/github/sspanak/tt9/ime/helpers/TextField.java +++ b/app/src/main/java/io/github/sspanak/tt9/ime/helpers/TextField.java @@ -294,8 +294,8 @@ public class TextField { return word; } - // nothing to highlight in an empty word or if the target is beyond the last letter - if (word == null || word.length() == 0 || word.length() <= start) { + // nothing to highlight in: an empty string; after the last letter; in special characters or emoji, because it breaks them + if (word == null || word.length() == 0 || word.length() <= start || !Character.isLetterOrDigit(word.charAt(0))) { return word; } diff --git a/app/src/main/java/io/github/sspanak/tt9/ui/UI.java b/app/src/main/java/io/github/sspanak/tt9/ui/UI.java index 7836f6e1..f2d7b400 100644 --- a/app/src/main/java/io/github/sspanak/tt9/ui/UI.java +++ b/app/src/main/java/io/github/sspanak/tt9/ui/UI.java @@ -3,37 +3,37 @@ package io.github.sspanak.tt9.ui; import android.app.AlertDialog; import android.content.Context; import android.content.Intent; +import android.inputmethodservice.InputMethodService; import android.os.Looper; import android.view.inputmethod.InputMethodManager; import android.widget.Toast; import androidx.annotation.NonNull; -import io.github.sspanak.tt9.ime.TraditionalT9; import io.github.sspanak.tt9.languages.Language; import io.github.sspanak.tt9.preferences.PreferencesActivity; public class UI { private static Toast toastLang = null; - public static void showAddWordDialog(TraditionalT9 tt9, int language, String currentWord) { - Intent intent = new Intent(tt9, PopupDialogActivity.class); + public static void showAddWordDialog(InputMethodService ims, int language, String currentWord) { + Intent intent = new Intent(ims, PopupDialogActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); intent.putExtra("word", currentWord); intent.putExtra("lang", language); intent.putExtra("popup_type", PopupDialogActivity.DIALOG_ADD_WORD_INTENT); - tt9.startActivity(intent); + ims.startActivity(intent); } - public static void showConfirmDictionaryUpdateDialog(TraditionalT9 tt9, int language) { - Intent intent = new Intent(tt9, PopupDialogActivity.class); + public static void showConfirmDictionaryUpdateDialog(InputMethodService ims, int language) { + Intent intent = new Intent(ims, PopupDialogActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); intent.putExtra("lang", language); intent.putExtra("popup_type", PopupDialogActivity.DIALOG_CONFIRM_WORDS_UPDATE_INTENT); - tt9.startActivity(intent); + ims.startActivity(intent); } @@ -42,12 +42,12 @@ public class UI { } - public static void showSettingsScreen(TraditionalT9 tt9) { - Intent prefIntent = new Intent(tt9, PreferencesActivity.class); + public static void showSettingsScreen(InputMethodService ims) { + Intent prefIntent = new Intent(ims, PreferencesActivity.class); prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); prefIntent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); - tt9.hideWindow(); - tt9.startActivity(prefIntent); + ims.hideWindow(); + ims.startActivity(prefIntent); } public static void alert(Context context, int titleResource, int messageResource) { diff --git a/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftKey.java b/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftKey.java index 26a29c0f..a6d1b6a2 100644 --- a/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftKey.java +++ b/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftKey.java @@ -15,12 +15,12 @@ import android.view.View; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; -import io.github.sspanak.tt9.util.Logger; import io.github.sspanak.tt9.R; import io.github.sspanak.tt9.ime.TraditionalT9; import io.github.sspanak.tt9.languages.Language; import io.github.sspanak.tt9.languages.LanguageCollection; import io.github.sspanak.tt9.preferences.SettingsStore; +import io.github.sspanak.tt9.util.Logger; public class SoftKey extends androidx.appcompat.widget.AppCompatButton implements View.OnTouchListener, View.OnLongClickListener { protected TraditionalT9 tt9; diff --git a/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftPunctuationKey.java b/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftPunctuationKey.java index 1d63ec2c..2b20908d 100644 --- a/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftPunctuationKey.java +++ b/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftPunctuationKey.java @@ -21,7 +21,7 @@ public class SoftPunctuationKey extends SoftKey { @Override protected boolean handleRelease() { - return tt9.onText(getKeyChar()); + return tt9.onText(getKeyChar(), false); } @Override diff --git a/app/src/main/java/io/github/sspanak/tt9/ui/tray/SuggestionsBar.java b/app/src/main/java/io/github/sspanak/tt9/ui/tray/SuggestionsBar.java index 3c50a784..75943fcf 100644 --- a/app/src/main/java/io/github/sspanak/tt9/ui/tray/SuggestionsBar.java +++ b/app/src/main/java/io/github/sspanak/tt9/ui/tray/SuggestionsBar.java @@ -18,7 +18,7 @@ import java.util.ArrayList; import java.util.List; import io.github.sspanak.tt9.R; -import io.github.sspanak.tt9.ime.TraditionalT9; +import io.github.sspanak.tt9.ime.AbstractHandler; import io.github.sspanak.tt9.preferences.SettingsStore; public class SuggestionsBar { @@ -27,14 +27,14 @@ public class SuggestionsBar { private boolean isDarkThemeEnabled = false; private final RecyclerView mView; - private final TraditionalT9 tt9; + private final AbstractHandler tt9; private SuggestionsAdapter mSuggestionsAdapter; private final Handler alternativeScrollingHandler = new Handler(); private final int suggestionScrollingDelay; - public SuggestionsBar(TraditionalT9 tt9, View mainView) { + public SuggestionsBar(AbstractHandler tt9, View mainView) { super(); this.tt9 = tt9; @@ -100,11 +100,6 @@ public class SuggestionsBar { } - public String getCurrentSuggestion() { - return getSuggestion(selectedIndex); - } - - @NonNull public String getSuggestion(int id) { if (id < 0 || id >= suggestions.size()) {