1
0
Fork 0

separated TraditionalT9 into smaller focused handlers, as much as the IME architecture permits

This commit is contained in:
sspanak 2024-03-19 20:15:15 +02:00 committed by Dimo Karaivanov
parent 58f5123bdb
commit 50fedcad13
14 changed files with 929 additions and 818 deletions

View file

@ -2,6 +2,7 @@ package io.github.sspanak.tt9.db;
import android.content.Context; import android.content.Context;
import android.content.res.AssetManager; import android.content.res.AssetManager;
import android.inputmethodservice.InputMethodService;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
@ -10,9 +11,6 @@ import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.Locale; 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.WordBatch;
import io.github.sspanak.tt9.db.entities.WordFile; import io.github.sspanak.tt9.db.entities.WordFile;
import io.github.sspanak.tt9.db.exceptions.DictionaryImportAbortedException; 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.InsertOps;
import io.github.sspanak.tt9.db.sqlite.SQLiteOpener; import io.github.sspanak.tt9.db.sqlite.SQLiteOpener;
import io.github.sspanak.tt9.db.sqlite.Tables; 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.EmojiLanguage;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.languages.exceptions.InvalidLanguageCharactersException; import io.github.sspanak.tt9.languages.exceptions.InvalidLanguageCharactersException;
import io.github.sspanak.tt9.languages.exceptions.InvalidLanguageException; 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.preferences.SettingsStore;
import io.github.sspanak.tt9.ui.DictionaryLoadingBar; import io.github.sspanak.tt9.ui.DictionaryLoadingBar;
import io.github.sspanak.tt9.ui.UI; 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 { public class DictionaryLoader {
private static final String LOG_TAG = "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()) { if (getInstance(context).isRunning()) {
return; return;
} }

View file

@ -6,8 +6,6 @@ import androidx.annotation.NonNull;
import java.util.ArrayList; 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.Word;
import io.github.sspanak.tt9.db.entities.WordList; import io.github.sspanak.tt9.db.entities.WordList;
import io.github.sspanak.tt9.db.sqlite.DeleteOps; 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.ReadOps;
import io.github.sspanak.tt9.db.sqlite.SQLiteOpener; import io.github.sspanak.tt9.db.sqlite.SQLiteOpener;
import io.github.sspanak.tt9.db.sqlite.UpdateOps; 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.EmojiLanguage;
import io.github.sspanak.tt9.languages.Language; import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.languages.NullLanguage; 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.preferences.SettingsStore;
import io.github.sspanak.tt9.ui.dialogs.AddWordDialog; 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 { public class WordStore {
@ -32,7 +31,7 @@ public class WordStore {
private ReadOps readOps = null; private ReadOps readOps = null;
public WordStore(@NonNull Context context) { private WordStore(@NonNull Context context) {
try { try {
sqlite = SQLiteOpener.getInstance(context); sqlite = SQLiteOpener.getInstance(context);
sqlite.getDb(); 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) { if (self == null) {
context = context == null ? TraditionalT9.getMainContext() : context;
self = new WordStore(context); self = new WordStore(context);
} }

View file

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

View file

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

View file

@ -1,19 +1,17 @@
package io.github.sspanak.tt9.ime; package io.github.sspanak.tt9.ime;
import android.inputmethodservice.InputMethodService;
import android.view.KeyEvent; import android.view.KeyEvent;
import android.view.View; import android.view.View;
import android.view.inputmethod.EditorInfo; 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.ime.helpers.Key;
import io.github.sspanak.tt9.preferences.SettingsStore; import io.github.sspanak.tt9.preferences.SettingsStore;
import io.github.sspanak.tt9.preferences.screens.debug.ItemInputHandlingMode; 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; protected SettingsStore settings;
// debounce handling // debounce handling
@ -66,7 +64,7 @@ abstract class KeyPadHandler extends InputMethodService {
*/ */
@Override @Override
public View onCreateInputView() { public View onCreateInputView() {
return createSoftKeyView(); return createMainView();
} }
@ -105,7 +103,6 @@ abstract class KeyPadHandler extends InputMethodService {
@Override @Override
public void onFinishInput() { public void onFinishInput() {
super.onFinishInput(); super.onFinishInput();
// Logger.d("onFinishInput", "When is this called?");
onStop(); onStop();
} }
@ -256,43 +253,7 @@ abstract class KeyPadHandler extends InputMethodService {
private boolean handleHotkey(int keyCode, boolean hold, boolean repeat, boolean validateOnly) { private boolean handleHotkey(int keyCode, boolean hold, boolean repeat, boolean validateOnly) {
if (keyCode == settings.getKeyAddWord() * (hold ? -1 : 1)) { return onHotkey(keyCode * (hold ? -1 : 1), repeat, validateOnly);
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;
} }
@ -321,34 +282,4 @@ abstract class KeyPadHandler extends InputMethodService {
return false; 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();
} }

View file

@ -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<String> onDelayedAccept;
@NonNull protected final SuggestionsBar suggestionBar;
@NonNull private TextField textField;
SuggestionOps(@NonNull TypingHandler tt9, View mainView, @NonNull ConsumerCompat<String> 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<String> suggestions) {
suggestionBar.setSuggestions(suggestions, 0);
}
void set(ArrayList<String> 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);
}
}

View file

@ -1,11 +1,9 @@
package io.github.sspanak.tt9.ime; package io.github.sspanak.tt9.ime;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.os.Build; import android.os.Build;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.view.KeyEvent;
import android.view.View; import android.view.View;
import android.view.inputmethod.EditorInfo; import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputConnection;
@ -13,130 +11,21 @@ import android.view.inputmethod.InputMethodManager;
import androidx.annotation.NonNull; 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.DictionaryLoader;
import io.github.sspanak.tt9.db.WordStoreAsync; 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.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.SettingsStore;
import io.github.sspanak.tt9.preferences.helpers.Hotkeys;
import io.github.sspanak.tt9.ui.PopupDialogActivity; import io.github.sspanak.tt9.ui.PopupDialogActivity;
import io.github.sspanak.tt9.ui.UI; import io.github.sspanak.tt9.ui.UI;
import io.github.sspanak.tt9.ui.main.MainView; import io.github.sspanak.tt9.ui.main.MainView;
import io.github.sspanak.tt9.ui.tray.StatusBar; 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.Logger;
import io.github.sspanak.tt9.util.Text;
public class TraditionalT9 extends KeyPadHandler { public class TraditionalT9 extends HotkeyHandler {
private InputConnection currentInputConnection = null; @NonNull
// internal settings/data private final Handler normalizationHandler = new Handler(Looper.getMainLooper());
@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<Integer> allowedInputModes = new ArrayList<>();
@NonNull private InputMode mInputMode = InputMode.getInstance(null, null, null, InputMode.MODE_PASSTHROUGH);
// language
protected ArrayList<Integer> mEnabledLanguages;
protected Language mLanguage;
protected Language systemLanguage;
// soft key view
private MainView mainView = null; private MainView mainView = null;
private StatusBar statusBar = 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 @Override
@ -147,7 +36,7 @@ public class TraditionalT9 extends KeyPadHandler {
if (message != null) { if (message != null) {
forceShowWindowIfHidden(); forceShowWindowIfHidden();
if (!message.isEmpty()) { if (!message.isEmpty()) {
UI.toastLong(self, message); UI.toastLong(this, message);
} }
} }
@ -155,8 +44,8 @@ public class TraditionalT9 extends KeyPadHandler {
} }
@Override
protected void onInit() { protected void onInit() {
self = this;
Logger.setLevel(settings.getLogLevel()); Logger.setLevel(settings.getLogLevel());
WordStoreAsync.init(this); WordStoreAsync.init(this);
@ -166,42 +55,21 @@ public class TraditionalT9 extends KeyPadHandler {
initTray(); initTray();
} }
validateFunctionKeys(); super.onInit();
}
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();
} }
private void initTray() { private void initTray() {
setInputView(mainView.getView()); setInputView(mainView.getView());
createSuggestionBar(mainView.getView());
statusBar = new StatusBar(mainView.getView()); statusBar = new StatusBar(mainView.getView());
suggestionBar = new SuggestionsBar(this, mainView.getView());
} }
private void setDarkTheme() { private void setDarkTheme() {
mainView.setDarkTheme(settings.getDarkTheme()); mainView.setDarkTheme(settings.getDarkTheme());
statusBar.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()) { if (mainView.createView()) {
initTray(); initTray();
} }
statusBar.setText(mInputMode.toString()); setStatusText(mInputMode.toString());
setDarkTheme(); setDarkTheme();
mainView.render(); mainView.render();
} }
@Override
protected void onStart(InputConnection connection, EditorInfo field) { protected void onStart(InputConnection connection, EditorInfo field) {
Logger.setLevel(settings.getLogLevel()); Logger.setLevel(settings.getLogLevel());
setInputField(connection, field); super.onStart(connection, field);
initTyping();
if (mInputMode.isPassthrough()) { if (mInputMode.isPassthrough()) {
// When the input is invalid or simple, let Android handle it. // When the input is invalid or simple, let Android handle it.
@ -229,25 +97,25 @@ public class TraditionalT9 extends KeyPadHandler {
} }
normalizationHandler.removeCallbacksAndMessages(null); normalizationHandler.removeCallbacksAndMessages(null);
systemLanguage = LanguageCollection.getDefault(this);
initUi(); initUi();
updateInputViewShown(); updateInputViewShown();
} }
@Override
protected void onFinishTyping() { protected void onFinishTyping() {
cancelAutoAccept();
if (!(mInputMode instanceof ModePassthrough)) { if (!(mInputMode instanceof ModePassthrough)) {
DictionaryLoader.autoLoad(this, mLanguage); DictionaryLoader.autoLoad(this, mLanguage);
} }
mInputMode = InputMode.getInstance(null, null, null, InputMode.MODE_PASSTHROUGH); super.onFinishTyping();
} }
@Override
protected void onStop() { protected void onStop() {
onFinishTyping(); onFinishTyping();
clearSuggestions(); suggestionOps.clear();
statusBar.setText("--"); statusBar.setText("--");
normalizationHandler.removeCallbacksAndMessages(null); 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 * createMainView
*
* @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<String> suggestions) {
setSuggestions(suggestions, 0);
}
private void setSuggestions(List<String> 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
* Generates the actual UI of TT9. * Generates the actual UI of TT9.
*/ */
protected View createSoftKeyView() { protected View createMainView() {
mainView.forceCreateView(); mainView.forceCreateView();
initTray(); initTray();
setDarkTheme(); 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 * forceShowWindowIfHidden
* Some applications may hide our window and it remains invisible until the screen is touched or OK is pressed. * 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 @Override
protected boolean shouldBeVisible() { protected boolean shouldBeVisible() {
return !getInputMode().isPassthrough(); return !getInputMode().isPassthrough();
@ -854,4 +182,31 @@ public class TraditionalT9 extends KeyPadHandler {
protected boolean shouldBeOff() { protected boolean shouldBeOff() {
return currentInputConnection == null || mInputMode.isPassthrough(); 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;
}
} }

View file

@ -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<Integer> allowedInputModes = new ArrayList<>();
@NonNull
protected InputMode mInputMode = InputMode.getInstance(null, null, null, InputMode.MODE_PASSTHROUGH);
// language
protected ArrayList<Integer> 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);
}
}

View file

@ -83,13 +83,12 @@ public class AppHacks {
* setComposingTextWithHighlightedStem * setComposingTextWithHighlightedStem
* A compatibility function for text fields that do not support SpannableString. Effectively disables highlighting. * 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()) { if (isKindleInvertedTextField()) {
textField.setComposingText(word); textField.setComposingText(word);
return true; } else {
textField.setComposingTextWithHighlightedStem(word, inputMode);
} }
return false;
} }

View file

@ -294,8 +294,8 @@ public class TextField {
return word; return word;
} }
// nothing to highlight in an empty word or if the target is beyond the last letter // 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) { if (word == null || word.length() == 0 || word.length() <= start || !Character.isLetterOrDigit(word.charAt(0))) {
return word; return word;
} }

View file

@ -3,37 +3,37 @@ package io.github.sspanak.tt9.ui;
import android.app.AlertDialog; import android.app.AlertDialog;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.inputmethodservice.InputMethodService;
import android.os.Looper; import android.os.Looper;
import android.view.inputmethod.InputMethodManager; import android.view.inputmethod.InputMethodManager;
import android.widget.Toast; import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import io.github.sspanak.tt9.ime.TraditionalT9;
import io.github.sspanak.tt9.languages.Language; import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.preferences.PreferencesActivity; import io.github.sspanak.tt9.preferences.PreferencesActivity;
public class UI { public class UI {
private static Toast toastLang = null; private static Toast toastLang = null;
public static void showAddWordDialog(TraditionalT9 tt9, int language, String currentWord) { public static void showAddWordDialog(InputMethodService ims, int language, String currentWord) {
Intent intent = new Intent(tt9, PopupDialogActivity.class); Intent intent = new Intent(ims, PopupDialogActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY);
intent.putExtra("word", currentWord); intent.putExtra("word", currentWord);
intent.putExtra("lang", language); intent.putExtra("lang", language);
intent.putExtra("popup_type", PopupDialogActivity.DIALOG_ADD_WORD_INTENT); intent.putExtra("popup_type", PopupDialogActivity.DIALOG_ADD_WORD_INTENT);
tt9.startActivity(intent); ims.startActivity(intent);
} }
public static void showConfirmDictionaryUpdateDialog(TraditionalT9 tt9, int language) { public static void showConfirmDictionaryUpdateDialog(InputMethodService ims, int language) {
Intent intent = new Intent(tt9, PopupDialogActivity.class); Intent intent = new Intent(ims, PopupDialogActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY);
intent.putExtra("lang", language); intent.putExtra("lang", language);
intent.putExtra("popup_type", PopupDialogActivity.DIALOG_CONFIRM_WORDS_UPDATE_INTENT); 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) { public static void showSettingsScreen(InputMethodService ims) {
Intent prefIntent = new Intent(tt9, PreferencesActivity.class); Intent prefIntent = new Intent(ims, PreferencesActivity.class);
prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); prefIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
prefIntent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); prefIntent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY);
tt9.hideWindow(); ims.hideWindow();
tt9.startActivity(prefIntent); ims.startActivity(prefIntent);
} }
public static void alert(Context context, int titleResource, int messageResource) { public static void alert(Context context, int titleResource, int messageResource) {

View file

@ -15,12 +15,12 @@ import android.view.View;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import io.github.sspanak.tt9.util.Logger;
import io.github.sspanak.tt9.R; import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.ime.TraditionalT9; import io.github.sspanak.tt9.ime.TraditionalT9;
import io.github.sspanak.tt9.languages.Language; import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.languages.LanguageCollection; import io.github.sspanak.tt9.languages.LanguageCollection;
import io.github.sspanak.tt9.preferences.SettingsStore; 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 { public class SoftKey extends androidx.appcompat.widget.AppCompatButton implements View.OnTouchListener, View.OnLongClickListener {
protected TraditionalT9 tt9; protected TraditionalT9 tt9;

View file

@ -21,7 +21,7 @@ public class SoftPunctuationKey extends SoftKey {
@Override @Override
protected boolean handleRelease() { protected boolean handleRelease() {
return tt9.onText(getKeyChar()); return tt9.onText(getKeyChar(), false);
} }
@Override @Override

View file

@ -18,7 +18,7 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import io.github.sspanak.tt9.R; 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; import io.github.sspanak.tt9.preferences.SettingsStore;
public class SuggestionsBar { public class SuggestionsBar {
@ -27,14 +27,14 @@ public class SuggestionsBar {
private boolean isDarkThemeEnabled = false; private boolean isDarkThemeEnabled = false;
private final RecyclerView mView; private final RecyclerView mView;
private final TraditionalT9 tt9; private final AbstractHandler tt9;
private SuggestionsAdapter mSuggestionsAdapter; private SuggestionsAdapter mSuggestionsAdapter;
private final Handler alternativeScrollingHandler = new Handler(); private final Handler alternativeScrollingHandler = new Handler();
private final int suggestionScrollingDelay; private final int suggestionScrollingDelay;
public SuggestionsBar(TraditionalT9 tt9, View mainView) { public SuggestionsBar(AbstractHandler tt9, View mainView) {
super(); super();
this.tt9 = tt9; this.tt9 = tt9;
@ -100,11 +100,6 @@ public class SuggestionsBar {
} }
public String getCurrentSuggestion() {
return getSuggestion(selectedIndex);
}
@NonNull @NonNull
public String getSuggestion(int id) { public String getSuggestion(int id) {
if (id < 0 || id >= suggestions.size()) { if (id < 0 || id >= suggestions.size()) {