Merge branch 'gameinput'

This commit is contained in:
jkds
2026-01-04 05:13:35 +01:00
23 changed files with 1691 additions and 1227 deletions

2
bin/app/Cargo.lock generated
View File

@@ -3708,7 +3708,7 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniquad"
version = "0.4.8"
source = "git+https://github.com/narodnik/miniquad#f9bf3a41e40f8b15032c159e24dec8e40c7adfa5"
source = "git+https://github.com/not-fl3/miniquad#dd44380a7ace19462bb63e53dce6093d28219aa2"
dependencies = [
"libc",
"ndk-sys",

View File

@@ -9,8 +9,7 @@ homepage = "https://dark.fi"
repository = "https://codeberg.org/darkrenaissance/darkfi"
[dependencies]
#miniquad = { git = "https://github.com/not-fl3/miniquad" }
miniquad = { git = "https://github.com/narodnik/miniquad" }
miniquad = { git = "https://github.com/not-fl3/miniquad" }
# Currently latest version links to freetype-sys 0.19 but we use 0.21
#harfbuzz-sys = "0.6.1"

View File

@@ -95,6 +95,9 @@ install-apk:
-mv $(DEBUG_APK) .
-adb $(ADB_DEVICE) uninstall darkfi.darkfi_app
adb $(ADB_DEVICE) install -r darkfi-app.apk
$(MAKE) log-apk
log-apk:
reset
adb $(ADB_DEVICE) logcat -c
adb $(ADB_DEVICE) shell monkey -p darkfi.darkfi_app -c android.intent.category.LAUNCHER 1

View File

@@ -1,20 +1,79 @@
//% IMPORTS
import android.view.ViewGroup;
import android.text.Editable;
import android.text.Spannable;
import android.text.SpanWatcher;
import android.text.Spanned;
import android.text.TextWatcher;
import android.widget.EditText;
import android.widget.TextView;
import android.view.inputmethod.BaseInputConnection;
import android.view.WindowInsets.Type;
import android.view.inputmethod.EditorInfo;
import android.text.InputType;
import android.util.Log;
import java.util.HashMap;
import autosuggest.InvisibleInputView;
import autosuggest.CustomInputConnection;
import videodecode.VideoDecoder;
import textinput.Settings;
import textinput.Listener;
import textinput.State;
import textinput.GameTextInput;
//% END
//% QUAD_SURFACE_ON_CREATE_INPUT_CONNECTION
MainActivity main = (MainActivity)getContext();
// Create InputConnection if it doesn't exist yet
if (main.inpcon == null) {
EditorInfo editorInfo = new EditorInfo();
editorInfo.inputType = InputType.TYPE_CLASS_TEXT |
InputType.TYPE_TEXT_FLAG_MULTI_LINE |
InputType.TYPE_TEXT_FLAG_AUTO_CORRECT;
editorInfo.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN;
main.inpcon = new textinput.InputConnection(
getContext(),
this,
new Settings(editorInfo, true)
);
// Pass the InputConnection to native GameTextInput library
main.setInputConnectionNative(main.inpcon);
}
// Set the listener to receive IME state changes
main.inpcon.setListener(new Listener() {
@Override
public void stateChanged(State newState, boolean dismissed) {
// Called when the IME sends new text state
// Forward to native code which triggers Rust callback
Log.d("darkfi", "stateChanged: text=" + newState.toString());
main.onTextInputEventNative(newState);
}
@Override
public void onImeInsetsChanged(androidx.core.graphics.Insets insets) {
// Called when IME insets change (e.g., keyboard height changes)
// Optional: can be used for dynamic layout adjustment
}
@Override
public void onSoftwareKeyboardVisibilityChanged(boolean visible) {
// Called when keyboard is shown or hidden
}
@Override
public void onEditorAction(int actionCode) {
// Called when user presses action button (Done, Next, etc.)
// Optional: handle specific editor actions
}
});
// Copy EditorInfo from GameTextInput to configure IME
if (outAttrs != null) {
GameTextInput.copyEditorInfo(
main.inpcon.getEditorInfo(),
outAttrs
);
}
// Return the GameTextInput InputConnection to IME
if (true) return main.inpcon;
//% END
@@ -51,146 +110,12 @@ if (true)
//% MAIN_ACTIVITY_BODY
private ViewGroup rootView;
// GameTextInput native bridge functions (following official Android pattern)
native void setInputConnectionNative(textinput.InputConnection c);
native void onTextInputEventNative(textinput.State softKeyboardEvent);
private HashMap<Integer, InvisibleInputView> editors;
native static void onInitEdit(int id);
public void createComposer(final int id) {
Log.d("darkfi", "createComposer() -> " + id);
final InvisibleInputView iv = new InvisibleInputView(this, id);
editors.put(id, iv);
runOnUiThread(new Runnable() {
@Override
public void run() {
rootView.addView(iv);
iv.clearFocus();
onInitEdit(id);
}
});
}
private InputMethodManager getIMM() {
return (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE);
}
public boolean focus(final int id) {
final InvisibleInputView iv = editors.get(id);
if (iv == null) {
return false;
}
runOnUiThread(new Runnable() {
@Override
public void run() {
boolean isFocused = iv.requestFocus();
// Just Android things ;)
if (!isFocused) {
Log.w("darkfi", "error requesting focus for id=" + id + ": " + iv);
}
getIMM().showSoftInput(iv, InputMethodManager.SHOW_IMPLICIT);
}
});
return true;
}
public boolean unfocus(final int id) {
final InvisibleInputView iv = editors.get(id);
if (iv == null) {
return false;
}
runOnUiThread(new Runnable() {
@Override
public void run() {
iv.clearFocus();
getIMM().hideSoftInputFromWindow(iv.getWindowToken(), 0);
}
});
return true;
}
/*
public CustomInputConnection getInputConnect(int id) {
InvisibleInputView iv = editors.get(id);
if (iv == null) {
return null;
}
return iv.inputConnection;
}
*/
public InvisibleInputView getInputView(int id) {
return editors.get(id);
}
public boolean setText(int id, String txt) {
//Log.d("darkfi", "setText(" + id + ", " + txt + ")");
InvisibleInputView iv = editors.get(id);
if (iv == null) {
return false;
}
// If inputConnection is not yet ready, then setup the editable directly.
if (iv.inputConnection == null) {
iv.setEditableText(txt);
return true;
}
// Maybe do this on the UI thread?
iv.inputConnection.setEditableText(txt, txt.length(), txt.length(), 0, 0);
return true;
}
public boolean setSelection(int id, int start, int end) {
InvisibleInputView iv = editors.get(id);
if (iv == null) {
return false;
}
// If inputConnection is not yet ready, then setup the sel directly.
if (iv.inputConnection == null) {
iv.setSelection(start, end);
return true;
}
iv.inputConnection.beginBatchEdit();
// Not sure if this is needed
//if (start != end)
// iv.inputConnection.finishComposingText();
iv.inputConnection.setSelection(start, end);
iv.inputConnection.endBatchEdit();
return true;
}
public boolean commitText(int id, String txt) {
//Log.d("darkfi", "setText(" + id + ", " + txt + ")");
InvisibleInputView iv = editors.get(id);
if (iv == null) {
return false;
}
if (iv.inputConnection == null) {
return false;
}
iv.inputConnection.beginBatchEdit();
iv.inputConnection.finishComposingText();
iv.inputConnection.commitText(txt, 1);
iv.inputConnection.endBatchEdit();
return true;
}
/*
// Editable string with the spans displayed inline
public String getDebugEditableStr() {
String edit = view.inputConnection.debugEditableStr();
Log.d("darkfi", "getDebugEditableStr() -> " + edit);
return edit;
}
*/
// GameTextInput InputConnection reference (public for QuadSurface access)
public textinput.InputConnection inpcon;
public String getAppDataPath() {
return getApplicationContext().getDataDir().getAbsolutePath();
@@ -232,13 +157,6 @@ public VideoDecoder createVideoDecoder() {
//% MAIN_ACTIVITY_ON_CREATE
rootView = layout;
editors = new HashMap<>();
view.setFocusable(false);
view.setFocusableInTouchMode(false);
view.clearFocus();
// Start a foreground service so the app stays awake
Intent serviceIntent = new Intent(this, ForegroundService.class);
startForegroundService(serviceIntent);

View File

@@ -1,426 +0,0 @@
/* This file is part of DarkFi (https://dark.fi)
*
* Copyright (C) 2020-2026 Dyne.org foundation
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package autosuggest;
import android.content.Context;
import android.text.Editable;
import android.text.Selection;
import android.util.Log;
import android.view.KeyEvent;
import android.view.inputmethod.BaseInputConnection;
import android.view.inputmethod.EditorInfo;
import android.view.View;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
import android.view.inputmethod.SurroundingText;
import android.view.inputmethod.InputMethodManager;
public class CustomInputConnection extends BaseInputConnection {
private static final boolean DEBUG = false;
private int id = -1;
private View mInternalView;
private Editable mEditable;
private boolean mSingleLine;
private int numBatchEdits;
private boolean shouldUpdateImeSelection;
native static void onCompose(int id, String text, int newCursorPos, boolean isCommit);
native static void onSetComposeRegion(int id, int start, int end);
native static void onFinishCompose(int id);
native static void onDeleteSurroundingText(int id, int left, int right);
public CustomInputConnection(int id, Editable editable, View view) {
super(view, true);
log("CustomInputConnection()");
this.id = id;
mEditable = editable;
mInternalView = view;
mSingleLine = false;
}
private void log(String fstr, Object... args) {
if (!DEBUG) return;
String text = "(" + id + "): " + String.format(fstr, args);
Log.d("darkfi", text);
}
/**
* Updates the AdapterInputConnection's internal representation of the text
* being edited and its selection and composition properties. The resulting
* Editable is accessible through the getEditable() method.
* If the text has not changed, this also calls updateSelection on the InputMethodManager.
* @param text The String contents of the field being edited
* @param selectionStart The character offset of the selection start, or the caret
* position if there is no selection
* @param selectionEnd The character offset of the selection end, or the caret
* position if there is no selection
* @param compositionStart The character offset of the composition start, or -1
* if there is no composition
* @param compositionEnd The character offset of the composition end, or -1
* if there is no selection
*/
public void setEditableText(String text, int selectionStart, int selectionEnd,
int compositionStart, int compositionEnd) {
log("setEditableText(%s, %d, %d, %d, %d)", text,
selectionStart, selectionEnd,
compositionStart, compositionEnd);
int prevSelectionStart = Selection.getSelectionStart(mEditable);
int prevSelectionEnd = Selection.getSelectionEnd(mEditable);
int prevEditableLength = mEditable.length();
int prevCompositionStart = getComposingSpanStart(mEditable);
int prevCompositionEnd = getComposingSpanEnd(mEditable);
String prevText = mEditable.toString();
selectionStart = Math.min(selectionStart, text.length());
selectionEnd = Math.min(selectionEnd, text.length());
compositionStart = Math.min(compositionStart, text.length());
compositionEnd = Math.min(compositionEnd, text.length());
boolean textUnchanged = prevText.equals(text);
if (textUnchanged
&& prevSelectionStart == selectionStart && prevSelectionEnd == selectionEnd
&& prevCompositionStart == compositionStart
&& prevCompositionEnd == compositionEnd) {
// Nothing has changed; don't need to do anything
return;
}
// When a programmatic change has been made to the editable field, both the start
// and end positions for the composition will equal zero. In this case we cancel the
// active composition in the editor as this no longer is relevant.
if (textUnchanged && compositionStart == 0 && compositionEnd == 0) {
cancelComposition();
}
if (!textUnchanged) {
log("replace mEditable with: %s", text);
mEditable.replace(0, mEditable.length(), text);
}
Selection.setSelection(mEditable, selectionStart, selectionEnd);
super.setComposingRegion(compositionStart, compositionEnd);
//log("textUnchanged=%s prevText=%s", textUnchanged, prevText);
//if (textUnchanged || prevText.equals("")) {
// log("setEditableText updating selection");
// updateSelection should be called when a manual selection change occurs.
// Should not be called if text is being entered else issues can occur
// e.g. backspace to undo autocorrection will not work with the default OSK.
getInputMethodManager().updateSelection(mInternalView,
selectionStart, selectionEnd, compositionStart, compositionEnd);
//}
}
@Override
public Editable getEditable() {
log("getEditable() -> %s", editableToXml(mEditable));
return mEditable;
}
@Override
public boolean setComposingText(CharSequence text, int newCursorPosition) {
log("setComposingText(%s, %d)", text, newCursorPosition);
super.setComposingText(text, newCursorPosition);
shouldUpdateImeSelection = true;
onCompose(id, text.toString(), newCursorPosition, false);
return true;
}
@Override
public boolean commitText(CharSequence text, int newCursorPosition) {
log("commitText(%s, %d)", text, newCursorPosition);
super.commitText(text, newCursorPosition);
shouldUpdateImeSelection = true;
onCompose(id, text.toString(), newCursorPosition, text.length() > 0);
return true;
}
@Override
public boolean performEditorAction(int actionCode) {
log("performEditorAction(%d)", actionCode);
switch (actionCode) {
case EditorInfo.IME_ACTION_NEXT:
cancelComposition();
// Send TAB key event
long timeStampMs = System.currentTimeMillis();
//mImeAdapter.sendSyntheticKeyEvent(
// sEventTypeRawKeyDown, timeStampMs, KeyEvent.KEYCODE_TAB, 0);
return true;
case EditorInfo.IME_ACTION_GO:
case EditorInfo.IME_ACTION_SEARCH:
//mImeAdapter.dismissInput(true);
break;
}
return super.performEditorAction(actionCode);
}
@Override
public boolean performContextMenuAction(int id) {
log("performContextMenuAction(%d)", id);
/*
switch (id) {
case android.R.id.selectAll:
return mImeAdapter.selectAll();
case android.R.id.cut:
return mImeAdapter.cut();
case android.R.id.copy:
return mImeAdapter.copy();
case android.R.id.paste:
return mImeAdapter.paste();
default:
return false;
}
*/
return false;
}
@Override
public CharSequence getTextAfterCursor(int length, int flags) {
log("getTextAfterCursor(%d, %d)", length, flags);
return super.getTextAfterCursor(length, flags);
}
@Override
public CharSequence getTextBeforeCursor(int length, int flags) {
log("getTextBeforeCursor(%d, %d)", length, flags);
return super.getTextBeforeCursor(length, flags);
}
@Override
public SurroundingText getSurroundingText(int beforeLength, int afterLength, int flags) {
log("getSurroundingText(%d, %d, %d)", beforeLength, afterLength, flags);
return super.getSurroundingText(beforeLength, afterLength, flags);
}
@Override
public CharSequence getSelectedText(int flags) {
log("getSelectedText(%d)", flags);
return super.getSelectedText(flags);
}
@Override
public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) {
log("getExtractedText(...)");
ExtractedText et = new ExtractedText();
et.text = mEditable.toString();
et.partialEndOffset = mEditable.length();
et.selectionStart = Selection.getSelectionStart(mEditable);
et.selectionEnd = Selection.getSelectionEnd(mEditable);
et.flags = mSingleLine ? ExtractedText.FLAG_SINGLE_LINE : 0;
return et;
}
@Override
public boolean deleteSurroundingText(int leftLength, int rightLength) {
log("deleteSurroundingText(%d, %d)", leftLength, rightLength);
if (!super.deleteSurroundingText(leftLength, rightLength)) {
return false;
}
shouldUpdateImeSelection = true;
//return mImeAdapter.deleteSurroundingText(leftLength, rightLength);
onDeleteSurroundingText(id, leftLength, rightLength);
return true;
}
@Override
public boolean sendKeyEvent(KeyEvent event) {
int action = event.getAction();
int keycode = event.getKeyCode();
log("sendKeyEvent() [action=%d, keycode=%d]", action, keycode);
//mImeAdapter.mSelectionHandleController.hideAndDisallowAutomaticShowing();
//mImeAdapter.mInsertionHandleController.hideAndDisallowAutomaticShowing();
// If this is a key-up, and backspace/del or if the key has a character representation,
// need to update the underlying Editable (i.e. the local representation of the text
// being edited).
if (event.getAction() == KeyEvent.ACTION_UP) {
if (event.getKeyCode() == KeyEvent.KEYCODE_DEL) {
super.deleteSurroundingText(1, 0);
} else if (event.getKeyCode() == KeyEvent.KEYCODE_FORWARD_DEL) {
super.deleteSurroundingText(0, 1);
} else {
int unicodeChar = event.getUnicodeChar();
if (unicodeChar != 0) {
Editable editable = getEditable();
int selectionStart = Selection.getSelectionStart(editable);
int selectionEnd = Selection.getSelectionEnd(editable);
if (selectionStart > selectionEnd) {
int temp = selectionStart;
selectionStart = selectionEnd;
selectionEnd = temp;
}
String inputChar = Character.toString((char)unicodeChar);
editable.replace(selectionStart, selectionEnd, inputChar);
onCompose(id, inputChar, selectionStart, true);
}
}
}
shouldUpdateImeSelection = true;
return super.sendKeyEvent(event);
}
@Override
public boolean finishComposingText() {
if (getComposingSpanStart(mEditable) == getComposingSpanEnd(mEditable)) {
log("finishComposingText() [DISABLED]");
return true;
}
log("finishComposingText()");
super.finishComposingText();
onFinishCompose(id);
return true;
}
@Override
public boolean setSelection(int start, int end) {
log("setSelection(%d, %d)", start, end);
if (start < 0 || end < 0) return true;
super.setSelection(start, end);
shouldUpdateImeSelection = true;
//return mImeAdapter.setEditableSelectionOffsets(start, end);
return true;
}
/**
* Informs the InputMethodManager and InputMethodSession (i.e. the IME) that there
* is no longer a current composition. Note this differs from finishComposingText, which
* is called by the IME when it wants to end a composition.
*/
void cancelComposition() {
log("cancelComposition()");
getInputMethodManager().restartInput(mInternalView);
}
@Override
public boolean setComposingRegion(int start, int end) {
log("setComposingRegion(%d, %d)", start, end);
int a = Math.min(start, end);
int b = Math.max(start, end);
super.setComposingRegion(a, b);
onSetComposeRegion(id, a, b);
return true;
}
boolean isActive() {
return getInputMethodManager().isActive();
}
private InputMethodManager getInputMethodManager() {
InputMethodManager imm = (InputMethodManager)mInternalView.getContext()
.getSystemService(Context.INPUT_METHOD_SERVICE);
if (imm == null) {
Log.e("darkfi", "[IC]: InputMethodManager is NULL!");
}
return imm;
}
private void updateImeSelection() {
log("updateImeSelection()");
getInputMethodManager().updateSelection(
mInternalView,
Selection.getSelectionStart(mEditable),
Selection.getSelectionEnd(mEditable),
getComposingSpanStart(mEditable),
getComposingSpanEnd(mEditable)
);
log("updateImeSelection() DONE");
}
@Override
public boolean beginBatchEdit() {
log("beginBatchEdit");
++numBatchEdits;
return false;
}
@Override
public boolean endBatchEdit() {
log("endBatchEdit");
if (--numBatchEdits == 0 && shouldUpdateImeSelection) {
updateImeSelection();
shouldUpdateImeSelection = false;
}
log("endBatchEdit DONE");
return false;
}
public static String editableToXml(Editable editable) {
StringBuilder xmlBuilder = new StringBuilder();
int length = editable.length();
Object[] spans = editable.getSpans(0, editable.length(), Object.class);
for (int i = 0; i < length; i++) {
// Find spans starting at this position
for (Object span : spans) {
if (editable.getSpanStart(span) == i) {
xmlBuilder
.append("<")
.append(span.getClass().getSimpleName())
.append(">");
}
}
// Append the character
char c = editable.charAt(i);
xmlBuilder.append(c);
if (Character.isHighSurrogate(c)) {
if (i + 1 < editable.length() && Character.isLowSurrogate(editable.charAt(i + 1))) {
i += 1;
xmlBuilder.append(editable.charAt(i));
}
}
// Find spans ending at this position
for (Object span : spans) {
if (editable.getSpanEnd(span) == i) {
xmlBuilder
.append("</")
.append(span.getClass().getSimpleName())
.append(">");
}
}
}
// Find spans starting at this position
for (Object span : spans) {
if (editable.getSpanStart(span) == length) {
xmlBuilder
.append("<")
.append(span.getClass().getSimpleName())
.append(">");
}
}
// Find spans ending at this position
for (Object span : spans) {
if (editable.getSpanEnd(span) == length) {
xmlBuilder
.append("</")
.append(span.getClass().getSimpleName())
.append(">");
}
}
return xmlBuilder.toString();
}
}

View File

@@ -1,179 +0,0 @@
/* This file is part of DarkFi (https://dark.fi)
*
* Copyright (C) 2020-2026 Dyne.org foundation
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package autosuggest;
import android.content.Context;
import android.graphics.Rect;
import android.text.Editable;
import android.text.Selection;
import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.BaseInputConnection;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import autosuggest.CustomInputConnection;
public class InvisibleInputView extends View {
public CustomInputConnection inputConnection;
public int id = -1;
public Editable editable;
native static void onCreateInputConnect(int id);
public InvisibleInputView(Context ctx, int id) {
super(ctx);
setFocusable(true);
setFocusableInTouchMode(true);
//setVisibility(INVISIBLE);
setVisibility(VISIBLE);
//setAlpha(0f);
setLayoutParams(new ViewGroup.LayoutParams(400, 200));
this.id = id;
editable = Editable.Factory.getInstance().newEditable("");
Selection.setSelection(editable, 0);
}
// Maybe move CustomInputConnection.setEditableText() to here?
// For now this is called when the InputConnection is not yet available.
public void setEditableText(String text) {
editable.replace(0, editable.length(), text);
Selection.setSelection(editable, text.length(), text.length());
}
// Same as above
public void setSelection(int start, int end) {
Selection.setSelection(editable, start, end);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
Log.d("darkfi", "InvisibleInputView " + id + " attached to window");
}
@Override
public boolean onCheckIsTextEditor() {
Log.d("darkfi", "onCheckIsTextEditor");
return true;
}
@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
Log.d("darkfi", "Create InputConnection for view=" + this.toString());
// Losing focus requires the inputConnection to be destroyed
//if (inputConnection != null) {
// Log.d("darkfi", " -> return existing InputConnection");
// return inputConnection;
//}
outAttrs.inputType = EditorInfo.TYPE_CLASS_TEXT
| EditorInfo.TYPE_TEXT_FLAG_AUTO_CORRECT;
//| EditorInfo.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT;
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN
//| EditorInfo.IME_ACTION_NONE;
| EditorInfo.IME_ACTION_GO;
outAttrs.initialSelStart = getSelectionStart();
outAttrs.initialSelEnd = getSelectionEnd();
//if (outAttrs.initialSelStart != 0) {
// Log.d("darkfi", " select: [" + outAttrs.initialSelStart + ", " +
// outAttrs.initialSelEnd + "]");
//}
inputConnection = new CustomInputConnection(id, editable, this);
onCreateInputConnect(id);
return inputConnection;
}
@Override
protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
Log.d("darkfi", "onFocusChanged: " + gainFocus);
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
Log.d("darkfi", "onKeyDown(" + keyCode + ", " + event + ")");
// Copied from CustomInputConnection
// Seems only the down event is sent.
int selectionStart = Selection.getSelectionStart(editable);
if (event.getAction() == KeyEvent.ACTION_DOWN) {
if (event.getKeyCode() == KeyEvent.KEYCODE_DEL) {
if (selectionStart > 0) {
editable.delete(selectionStart - 1, selectionStart);
CustomInputConnection.onDeleteSurroundingText(id, 1, 0);
}
} else if (event.getKeyCode() == KeyEvent.KEYCODE_FORWARD_DEL) {
if (selectionStart < editable.length()) {
editable.delete(selectionStart, selectionStart + 1);
CustomInputConnection.onDeleteSurroundingText(id, 0, 1);
}
} else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_LEFT) {
if (selectionStart > 0) {
Selection.setSelection(editable, selectionStart - 1);
CustomInputConnection.onSetComposeRegion(
id, selectionStart - 1, selectionStart);
}
} else if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_RIGHT) {
if (selectionStart < editable.length()) {
Selection.setSelection(editable, selectionStart + 1);
CustomInputConnection.onSetComposeRegion(
id, selectionStart + 1, selectionStart + 2);
}
} /* else {
int unicodeChar = event.getUnicodeChar();
if (unicodeChar != 0) {
int selectionEnd = Selection.getSelectionEnd(editable);
if (selectionStart > selectionEnd) {
int temp = selectionStart;
selectionStart = selectionEnd;
selectionEnd = temp;
}
String inputChar = Character.toString((char)unicodeChar);
Log.d("darkfi", "-> " + inputChar + " [" + selectionStart + ", " + selectionEnd + "]");
editable.replace(selectionStart, selectionEnd, inputChar);
CustomInputConnection.onCompose(
id, inputChar, selectionStart, true);
}
} */
}
return super.onKeyDown(keyCode, event);
}
public String debugEditableStr() {
return CustomInputConnection.editableToXml(editable);
}
public String rawText() {
return editable.toString();
}
public int getSelectionStart() {
return Selection.getSelectionStart(editable);
}
public int getSelectionEnd() {
return Selection.getSelectionEnd(editable);
}
public int getComposeStart() {
return BaseInputConnection.getComposingSpanStart(editable);
}
public int getComposeEnd() {
return BaseInputConnection.getComposingSpanEnd(editable);
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package textinput;
import android.text.Editable;
import android.text.Spanned;
import android.view.inputmethod.EditorInfo;
/*
* Singleton GameTextInput class with helper methods.
*/
public final class GameTextInput {
public final static void copyEditorInfo(EditorInfo from, EditorInfo to) {
if (from == null || to == null)
return;
if (from.hintText != null) {
to.hintText = from.hintText;
}
to.inputType = from.inputType;
to.imeOptions = from.imeOptions;
to.label = from.label;
to.initialCapsMode = from.initialCapsMode;
to.privateImeOptions = from.privateImeOptions;
if (from.packageName != null) {
to.packageName = from.packageName;
}
to.fieldId = from.fieldId;
if (from.fieldName != null) {
to.fieldName = from.fieldName;
}
to.initialSelStart = from.initialSelStart;
to.initialSelEnd = from.initialSelEnd;
}
public static final class Pair {
int first, second;
Pair(int f, int s) {
first = f;
second = s;
}
}
private GameTextInput() {}
}

View File

@@ -0,0 +1,716 @@
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package textinput;
import static android.view.inputmethod.EditorInfo.IME_ACTION_UNSPECIFIED;
import android.app.Activity;
import android.content.Context;
import android.os.Bundle;
import android.text.Editable;
import android.text.InputFilter;
import android.text.Selection;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
import android.view.inputmethod.BaseInputConnection;
import android.view.inputmethod.CompletionInfo;
import android.view.inputmethod.CorrectionInfo;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
import android.view.inputmethod.InputMethodManager;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import textinput.GameTextInput.Pair;
public class InputConnection extends BaseInputConnection implements View.OnKeyListener {
private final InputMethodManager imm;
private final View targetView;
private final Settings settings;
private final Editable mEditable;
private Listener listener;
private boolean mSoftKeyboardActive;
private void log(String text) {
//Log.d("darkfi", text);
}
/*
* This class filters EOL characters from the input. For details of how InputFilter.filter
* function works, refer to its documentation. If the suggested change is accepted without
* modifications, filter() should return null.
*/
private class SingeLineFilter implements InputFilter {
public CharSequence filter(
CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
boolean keepOriginal = true;
StringBuilder builder = new StringBuilder(end - start);
for (int i = start; i < end; i++) {
char c = source.charAt(i);
if (c == '\n') {
keepOriginal = false;
} else {
builder.append(c);
}
}
if (keepOriginal) {
return null;
}
if (source instanceof Spanned) {
SpannableString s = new SpannableString(builder);
TextUtils.copySpansFrom((Spanned) source, start, builder.length(), null, s, 0);
return s;
} else {
return builder;
}
}
}
private static final int MAX_LENGTH_FOR_SINGLE_LINE_EDIT_TEXT = 5000;
/**
* Constructor
*
* @param ctx The app's context
* @param targetView The view created this input connection
* @param settings EditorInfo and other settings needed by this class
* InputConnection.
*/
public InputConnection(Context ctx, View targetView, Settings settings) {
super(targetView, settings.mEditorInfo.inputType != 0);
log("InputConnection created");
this.targetView = targetView;
this.settings = settings;
Object imm = ctx.getSystemService(Context.INPUT_METHOD_SERVICE);
if (imm == null) {
throw new java.lang.RuntimeException("Can't get IMM");
} else {
this.imm = (InputMethodManager) imm;
this.mEditable = (Editable) (new SpannableStringBuilder());
}
// Listen for insets changes
WindowCompat.setDecorFitsSystemWindows(((Activity) targetView.getContext()).getWindow(), false);
targetView.setOnKeyListener(this);
// Apply EditorInfo settings
this.setEditorInfo(settings.mEditorInfo);
}
/**
* Restart the input method manager. This is useful to apply changes to the keyboard
* after calling setEditorInfo.
*/
public void restartInput() {
imm.restartInput(targetView);
}
/**
* Get whether the soft keyboard is visible.
*
* @return true if the soft keyboard is visible, false otherwise
*/
public final boolean getSoftKeyboardActive() {
return this.mSoftKeyboardActive;
}
/**
* Request the soft keyboard to become visible or invisible.
*
* @param active True if the soft keyboard should be made visible, otherwise false.
* @param flags See
* https://developer.android.com/reference/android/view/inputmethod/InputMethodManager#showSoftInput(android.view.View,%20int)
*/
public final void setSoftKeyboardActive(boolean active, int flags) {
log("setSoftKeyboardActive, active: " + active);
this.mSoftKeyboardActive = active;
if (active) {
this.targetView.setFocusableInTouchMode(true);
this.targetView.requestFocus();
this.imm.showSoftInput(this.targetView, flags);
} else {
this.imm.hideSoftInputFromWindow(this.targetView.getWindowToken(), flags);
}
restartInput();
}
/**
* Get the current EditorInfo used to configure the InputConnection's behaviour.
*
* @return The current EditorInfo.
*/
public final EditorInfo getEditorInfo() {
return this.settings.mEditorInfo;
}
/**
* Set the current EditorInfo used to configure the InputConnection's behaviour.
*
* @param editorInfo The EditorInfo to use
*/
public final void setEditorInfo(EditorInfo editorInfo) {
log("setEditorInfo");
settings.mEditorInfo = editorInfo;
// Depending on the multiline state, we might need a different set of filters.
// Filters are being used to filter specific characters for hardware keyboards
// (software input methods already support TYPE_TEXT_FLAG_MULTI_LINE).
if ((settings.mEditorInfo.inputType & EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE) == 0) {
mEditable.setFilters(
new InputFilter[] {new InputFilter.LengthFilter(MAX_LENGTH_FOR_SINGLE_LINE_EDIT_TEXT),
new SingeLineFilter()});
} else {
mEditable.setFilters(new InputFilter[] {});
}
}
/**
* Set the text, selection and composing region state.
*
* @param state The state to be used by the IME.
* This replaces any text, selections and composing regions currently active.
*/
public final void setState(State state) {
if (state == null)
return;
log(
"setState: '" + state.text + "', selection=(" + state.selectionStart + ","
+ state.selectionEnd + "), composing region=(" + state.composingRegionStart + ","
+ state.composingRegionEnd + ")");
mEditable.clear();
mEditable.clearSpans();
mEditable.insert(0, (CharSequence) state.text);
setSelection(state.selectionStart, state.selectionEnd);
if (state.composingRegionStart != state.composingRegionEnd) {
setComposingRegion(state.composingRegionStart, state.composingRegionEnd);
}
restartInput();
}
/**
* Get the current listener for state changes.
*
* @return The current Listener
*/
public final Listener getListener() {
return listener;
}
/**
* Set a listener for state changes.
*
* @param listener
* @return This InputConnection, for setter chaining.
*/
public final InputConnection setListener(Listener listener) {
this.listener = listener;
return this;
}
// From View.OnKeyListener
@Override
public boolean onKey(View view, int i, KeyEvent keyEvent) {
log("onKey: " + keyEvent);
if (!getSoftKeyboardActive()) {
return false;
}
// Don't call sendKeyEvent as it might produce an infinite loop.
if (processKeyEvent(keyEvent)) {
// IMM seems to cache the content of Editable, so we update it with restartInput
// Also it caches selection and composing region, so let's notify it about updates.
stateUpdated();
immUpdateSelection();
restartInput();
return true;
}
return false;
}
// From BaseInputConnection
@Override
public Editable getEditable() {
log("getEditable");
return mEditable;
}
// From BaseInputConnection
@Override
public boolean setSelection(int start, int end) {
log("setSelection: " + start + ":" + end);
return super.setSelection(start, end);
}
// From BaseInputConnection
@Override
public boolean setComposingText(CharSequence text, int newCursorPosition) {
log(
"setComposingText='" + text + "' newCursorPosition=" + newCursorPosition);
if (text == null) {
return false;
}
return super.setComposingText(text, newCursorPosition);
}
@Override
public boolean setComposingRegion(int start, int end) {
log("setComposingRegion: " + start + ":" + end);
return super.setComposingRegion(start, end);
}
// From BaseInputConnection
@Override
public boolean finishComposingText() {
log("finishComposingText");
return super.finishComposingText();
}
@Override
public boolean endBatchEdit() {
log("endBatchEdit");
stateUpdated();
return super.endBatchEdit();
}
@Override
public boolean commitCompletion(CompletionInfo text) {
log("commitCompletion");
return super.commitCompletion(text);
}
@Override
public boolean commitCorrection(CorrectionInfo text) {
log("commitCompletion");
return super.commitCorrection(text);
}
// From BaseInputConnection
@Override
public boolean commitText(CharSequence text, int newCursorPosition) {
log(
(new StringBuilder())
.append("commitText: ")
.append(text)
.append(", new pos = ")
.append(newCursorPosition)
.toString());
return super.commitText(text, newCursorPosition);
}
// From BaseInputConnection
@Override
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
log("deleteSurroundingText: " + beforeLength + ":" + afterLength);
return super.deleteSurroundingText(beforeLength, afterLength);
}
// From BaseInputConnection
@Override
public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) {
log("deleteSurroundingTextInCodePoints: " + beforeLength + ":" + afterLength);
return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength);
}
// From BaseInputConnection
@Override
public boolean sendKeyEvent(KeyEvent event) {
log("sendKeyEvent: " + event);
return super.sendKeyEvent(event);
}
// From BaseInputConnection
@Override
public CharSequence getSelectedText(int flags) {
CharSequence result = super.getSelectedText(flags);
if (result == null) {
result = "";
}
log("getSelectedText: " + flags + ", result: " + result);
return result;
}
// From BaseInputConnection
@Override
public CharSequence getTextAfterCursor(int length, int flags) {
log("getTextAfterCursor: " + length + ":" + flags);
if (length < 0) {
Log.i("darkfi", "getTextAfterCursor: returning null to due to an invalid length=" + length);
return null;
}
return super.getTextAfterCursor(length, flags);
}
// From BaseInputConnection
@Override
public CharSequence getTextBeforeCursor(int length, int flags) {
log("getTextBeforeCursor: " + length + ", flags=" + flags);
if (length < 0) {
Log.i("darkfi", "getTextBeforeCursor: returning null to due to an invalid length=" + length);
return null;
}
return super.getTextBeforeCursor(length, flags);
}
// From BaseInputConnection
@Override
public boolean requestCursorUpdates(int cursorUpdateMode) {
log("Request cursor updates: " + cursorUpdateMode);
return super.requestCursorUpdates(cursorUpdateMode);
}
// From BaseInputConnection
@Override
public void closeConnection() {
log("closeConnection");
super.closeConnection();
}
@Override
public boolean setImeConsumesInput(boolean imeConsumesInput) {
log("setImeConsumesInput: " + imeConsumesInput);
return super.setImeConsumesInput(imeConsumesInput);
}
@Override
public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) {
log("getExtractedText");
return super.getExtractedText(request, flags);
}
@Override
public boolean performPrivateCommand(String action, Bundle data) {
log("performPrivateCommand");
return super.performPrivateCommand(action, data);
}
private void immUpdateSelection() {
Pair selection = this.getSelection();
Pair cr = this.getComposingRegion();
log(
"immUpdateSelection: " + selection.first + "," + selection.second + ". " + cr.first + ","
+ cr.second);
settings.mEditorInfo.initialSelStart = selection.first;
settings.mEditorInfo.initialSelEnd = selection.second;
imm.updateSelection(targetView, selection.first, selection.second, cr.first, cr.second);
}
private Pair getSelection() {
return new Pair(Selection.getSelectionStart(mEditable), Selection.getSelectionEnd(mEditable));
}
private Pair getComposingRegion() {
return new Pair(getComposingSpanStart(mEditable), getComposingSpanEnd(mEditable));
}
private boolean processKeyEvent(KeyEvent event) {
if (event == null) {
return false;
}
int keyCode = event.getKeyCode();
log(
"processKeyEvent(key=" + keyCode + ") text=" + this.mEditable.toString());
// Filter out Enter keys if multi-line mode is disabled.
if ((settings.mEditorInfo.inputType & EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE) == 0
&& (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER)
&& event.hasNoModifiers()) {
sendEditorAction(settings.mEditorInfo.actionId);
return true;
}
if (event.getAction() != KeyEvent.ACTION_DOWN) {
return false;
}
// If no selection is set, move the selection to the end.
// This is the case when first typing on keys when the selection is not set.
// Note that for InputType.TYPE_CLASS_TEXT, this is not be needed because the
// selection is set in setComposingText.
Pair selection = this.getSelection();
if (selection.first == -1) {
selection.first = this.mEditable.length();
selection.second = this.mEditable.length();
}
if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
if (selection.first == selection.second) {
int newIndex = findIndexBackward(mEditable, selection.first, 1);
setSelection(newIndex, newIndex);
} else {
setSelection(selection.first, selection.first);
}
return true;
}
if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
if (selection.first == selection.second) {
int newIndex = findIndexForward(mEditable, selection.second, 1);
setSelection(newIndex, newIndex);
} else {
setSelection(selection.second, selection.second);
}
return true;
}
if (keyCode == KeyEvent.KEYCODE_MOVE_HOME) {
setSelection(0, 0);
return true;
}
if (keyCode == KeyEvent.KEYCODE_MOVE_END) {
setSelection(this.mEditable.length(), this.mEditable.length());
return true;
}
if (keyCode == KeyEvent.KEYCODE_DEL || keyCode == KeyEvent.KEYCODE_FORWARD_DEL) {
if (selection.first != selection.second) {
this.mEditable.delete(selection.first, selection.second);
return true;
}
if (keyCode == KeyEvent.KEYCODE_DEL) {
if (selection.first > 0) {
finishComposingText();
deleteSurroundingTextInCodePoints(1, 0);
return true;
}
}
if (keyCode == KeyEvent.KEYCODE_FORWARD_DEL) {
if (selection.first < this.mEditable.length()) {
finishComposingText();
deleteSurroundingTextInCodePoints(0, 1);
return true;
}
}
return false;
}
if (event.getUnicodeChar() == 0) {
return false;
}
if (selection.first != selection.second) {
log("processKeyEvent: deleting selection");
this.mEditable.delete(selection.first, selection.second);
}
String charsToInsert = Character.toString((char) event.getUnicodeChar());
this.mEditable.insert(selection.first, (CharSequence) charsToInsert);
int length = this.mEditable.length();
// Same logic as in setComposingText(): we must update composing region,
// so make sure it points to a valid range.
Pair composingRegion = this.getComposingRegion();
if (composingRegion.first == -1) {
composingRegion = this.getSelection();
if (composingRegion.first == -1) {
composingRegion = new Pair(0, 0);
}
}
composingRegion.second = composingRegion.first + length;
this.setComposingRegion(composingRegion.first, composingRegion.second);
int new_cursor = selection.first + charsToInsert.length();
setSelection(new_cursor, new_cursor);
log("processKeyEvent: exit, text=" + this.mEditable.toString());
return true;
}
private final void stateUpdated() {
Pair selection = this.getSelection();
Pair cr = this.getComposingRegion();
State state = new State(
this.mEditable.toString(), selection.first, selection.second, cr.first, cr.second);
settings.mEditorInfo.initialSelStart = selection.first;
settings.mEditorInfo.initialSelEnd = selection.second;
// Keep a reference to the listener to avoid a race condition when setting the listener.
Listener listener = this.listener;
// We always propagate state change events because unfortunately keyboard visibility functions
// are unreliable, and text editor logic should not depend on them.
if (listener != null) {
listener.stateChanged(state, /*dismissed=*/false);
}
}
/**
* Get the current IME insets.
*
* @return The current IME insets
*/
public Insets getImeInsets() {
if (this.targetView == null) {
return Insets.NONE;
}
WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(this.targetView);
if (insets == null) {
return Insets.NONE;
}
return insets.getInsets(WindowInsetsCompat.Type.ime());
}
/**
* Returns true if software keyboard is visible, false otherwise.
*
* @return whether software IME is visible or not.
*/
public boolean isSoftwareKeyboardVisible() {
if (this.targetView == null) {
return false;
}
WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(this.targetView);
if (insets == null) {
return false;
}
return insets.isVisible(WindowInsetsCompat.Type.ime());
}
/**
* This is an event handler from InputConnection interface.
* It's called when action button is triggered (typically this means Enter was pressed).
*
* @param action Action code, either one from EditorInfo.imeOptions or a custom one.
* @return Returns true on success, false if the input connection is no longer valid.
*/
@Override
public boolean performEditorAction(int action) {
log("performEditorAction, action=" + action);
if (action == IME_ACTION_UNSPECIFIED) {
// Super emulates Enter key press/release
return super.performEditorAction(action);
}
return sendEditorAction(action);
}
/**
* Delivers editor action to listener
*
* @param action Action code, either one from EditorInfo.imeOptions or a custom one.
* @return Returns true on success, false if the input connection is no longer valid.
*/
private boolean sendEditorAction(int action) {
Listener listener = this.listener;
if (listener != null) {
listener.onEditorAction(action);
return true;
}
return false;
}
private static int INVALID_INDEX = -1;
// Implementation copy from BaseInputConnection
private static int findIndexBackward(
final CharSequence cs, final int from, final int numCodePoints) {
int currentIndex = from;
boolean waitingHighSurrogate = false;
final int N = cs.length();
if (currentIndex < 0 || N < currentIndex) {
return INVALID_INDEX; // The starting point is out of range.
}
if (numCodePoints < 0) {
return INVALID_INDEX; // Basically this should not happen.
}
int remainingCodePoints = numCodePoints;
while (true) {
if (remainingCodePoints == 0) {
return currentIndex; // Reached to the requested length in code points.
}
--currentIndex;
if (currentIndex < 0) {
if (waitingHighSurrogate) {
return INVALID_INDEX; // An invalid surrogate pair is found.
}
return 0; // Reached to the beginning of the text w/o any invalid surrogate pair.
}
final char c = cs.charAt(currentIndex);
if (waitingHighSurrogate) {
if (!java.lang.Character.isHighSurrogate(c)) {
return INVALID_INDEX; // An invalid surrogate pair is found.
}
waitingHighSurrogate = false;
--remainingCodePoints;
continue;
}
if (!java.lang.Character.isSurrogate(c)) {
--remainingCodePoints;
continue;
}
if (java.lang.Character.isHighSurrogate(c)) {
return INVALID_INDEX; // A invalid surrogate pair is found.
}
waitingHighSurrogate = true;
}
}
// Implementation copy from BaseInputConnection
private static int findIndexForward(
final CharSequence cs, final int from, final int numCodePoints) {
int currentIndex = from;
boolean waitingLowSurrogate = false;
final int N = cs.length();
if (currentIndex < 0 || N < currentIndex) {
return INVALID_INDEX; // The starting point is out of range.
}
if (numCodePoints < 0) {
return INVALID_INDEX; // Basically this should not happen.
}
int remainingCodePoints = numCodePoints;
while (true) {
if (remainingCodePoints == 0) {
return currentIndex; // Reached to the requested length in code points.
}
if (currentIndex >= N) {
if (waitingLowSurrogate) {
return INVALID_INDEX; // An invalid surrogate pair is found.
}
return N; // Reached to the end of the text w/o any invalid surrogate pair.
}
final char c = cs.charAt(currentIndex);
if (waitingLowSurrogate) {
if (!java.lang.Character.isLowSurrogate(c)) {
return INVALID_INDEX; // An invalid surrogate pair is found.
}
--remainingCodePoints;
waitingLowSurrogate = false;
++currentIndex;
continue;
}
if (!java.lang.Character.isSurrogate(c)) {
--remainingCodePoints;
++currentIndex;
continue;
}
if (java.lang.Character.isLowSurrogate(c)) {
return INVALID_INDEX; // A invalid surrogate pair is found.
}
waitingLowSurrogate = true;
++currentIndex;
}
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package textinput;
import androidx.core.graphics.Insets;
/**
* Listener interface for text, selection and composing region changes.
* Also a listener for window insets changes.
*/
public interface Listener {
/*
* Called when the IME text, selection or composing region has changed.
*
* @param newState The updated state
* @param dismmissed Deprecated, don't use
*/
void stateChanged(State newState, boolean dismissed);
/*
* Called when the IME window insets change, i.e. the IME moves into or out of view.
*
* @param insets The new window insets, i.e. the offsets of top, bottom, left and right
* relative to the window
*/
void onImeInsetsChanged(Insets insets);
/*
* Called when the IME window is shown or hidden.
*
* @param insets True is IME is visible, false otherwise.
*/
void onSoftwareKeyboardVisibilityChanged(boolean visible);
/*
* Called when any editor action is performed. Typically this means that
* the Enter button has been pressed.
*
* @param action Code of the action. A default action is IME_ACTION_DONE.
*/
void onEditorAction(int action);
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package textinput;
import android.view.inputmethod.EditorInfo;
// Settings for InputConnection
public final class Settings {
EditorInfo mEditorInfo;
boolean mForwardKeyEvents;
public Settings(EditorInfo editorInfo, boolean forwardKeyEvents) {
mEditorInfo = editorInfo;
mForwardKeyEvents = forwardKeyEvents;
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package textinput;
// The state of an editable text region.
public final class State {
public State(String text_in, int selectionStart_in, int selectionEnd_in,
int composingRegionStart_in, int composingRegionEnd_in) {
text = text_in;
selectionStart = selectionStart_in;
selectionEnd = selectionEnd_in;
composingRegionStart = composingRegionStart_in;
composingRegionEnd = composingRegionEnd_in;
}
public String text;
public int selectionStart;
public int selectionEnd;
public int composingRegionStart;
public int composingRegionEnd;
}

View File

@@ -1,9 +1,17 @@
main_activity_inject = "java/MainActivity.java"
java_files = [
"java/autosuggest/CustomInputConnection.java",
"java/autosuggest/InvisibleInputView.java",
#"java/autosuggest/InvisibleInputManager.java",
"java/ForegroundService.java",
"java/videodecode/VideoDecoder.java"
"java/videodecode/VideoDecoder.java",
"java/textinput/InputConnection.java",
"java/textinput/State.java",
"java/textinput/Listener.java",
"java/textinput/Settings.java",
"java/textinput/GameTextInput.java"
]
comptime_jar_files = [
"android-libs/androidx/core-1.9.0.jar"
]
runtime_jar_files = [
"android-libs/androidx/core-1.9.0.jar"
]

View File

@@ -20,9 +20,9 @@ use miniquad::native::android::{self, ndk_sys, ndk_utils};
use parking_lot::Mutex as SyncMutex;
use std::{collections::HashMap, path::PathBuf, sync::LazyLock};
use crate::AndroidSuggestEvent;
pub mod insets;
pub mod textinput;
mod util;
pub mod vid;
macro_rules! call_mainactivity_int_method {
@@ -65,274 +65,6 @@ macro_rules! call_mainactivity_bool_method {
}};
}
struct GlobalData {
senders: HashMap<usize, async_channel::Sender<AndroidSuggestEvent>>,
next_id: usize,
}
fn send(id: usize, ev: AndroidSuggestEvent) {
let globals = &GLOBALS.lock();
let Some(sender) = globals.senders.get(&id) else {
warn!(target: "android", "Unknown composer_id={id} discard ev: {ev:?}");
return
};
let _ = sender.try_send(ev);
}
unsafe impl Send for GlobalData {}
unsafe impl Sync for GlobalData {}
static GLOBALS: LazyLock<SyncMutex<GlobalData>> =
LazyLock::new(|| SyncMutex::new(GlobalData { senders: HashMap::new(), next_id: 0 }));
#[no_mangle]
pub unsafe extern "C" fn Java_darkfi_darkfi_1app_MainActivity_onInitEdit(
_env: *mut ndk_sys::JNIEnv,
_: ndk_sys::jobject,
id: ndk_sys::jint,
) {
assert!(id >= 0);
let id = id as usize;
send(id, AndroidSuggestEvent::Init);
}
#[no_mangle]
pub unsafe extern "C" fn Java_autosuggest_InvisibleInputView_onCreateInputConnect(
_env: *mut ndk_sys::JNIEnv,
_: ndk_sys::jobject,
id: ndk_sys::jint,
) {
assert!(id >= 0);
let id = id as usize;
send(id, AndroidSuggestEvent::CreateInputConnect);
}
#[no_mangle]
pub unsafe extern "C" fn Java_autosuggest_CustomInputConnection_onCompose(
env: *mut ndk_sys::JNIEnv,
_: ndk_sys::jobject,
id: ndk_sys::jint,
text: ndk_sys::jobject,
cursor_pos: ndk_sys::jint,
is_commit: ndk_sys::jboolean,
) {
assert!(id >= 0);
let id = id as usize;
let text = ndk_utils::get_utf_str!(env, text);
send(
id,
AndroidSuggestEvent::Compose {
text: text.to_string(),
cursor_pos,
is_commit: is_commit == 1,
},
);
}
#[no_mangle]
pub unsafe extern "C" fn Java_autosuggest_CustomInputConnection_onSetComposeRegion(
_env: *mut ndk_sys::JNIEnv,
_: ndk_sys::jobject,
id: ndk_sys::jint,
start: ndk_sys::jint,
end: ndk_sys::jint,
) {
assert!(id >= 0);
let id = id as usize;
send(id, AndroidSuggestEvent::ComposeRegion { start: start as usize, end: end as usize });
}
#[no_mangle]
pub unsafe extern "C" fn Java_autosuggest_CustomInputConnection_onFinishCompose(
_env: *mut ndk_sys::JNIEnv,
_: ndk_sys::jobject,
id: ndk_sys::jint,
) {
assert!(id >= 0);
let id = id as usize;
send(id, AndroidSuggestEvent::FinishCompose);
}
#[no_mangle]
pub unsafe extern "C" fn Java_autosuggest_CustomInputConnection_onDeleteSurroundingText(
_env: *mut ndk_sys::JNIEnv,
_: ndk_sys::jobject,
id: ndk_sys::jint,
left: ndk_sys::jint,
right: ndk_sys::jint,
) {
assert!(id >= 0);
let id = id as usize;
send(
id,
AndroidSuggestEvent::DeleteSurroundingText { left: left as usize, right: right as usize },
);
}
pub fn create_composer(sender: async_channel::Sender<AndroidSuggestEvent>) -> usize {
let composer_id = {
let mut globals = GLOBALS.lock();
let id = globals.next_id;
globals.next_id += 1;
globals.senders.insert(id, sender);
id
};
unsafe {
let env = android::attach_jni_env();
ndk_utils::call_void_method!(env, android::ACTIVITY, "createComposer", "(I)V", composer_id);
}
composer_id
}
pub fn focus(id: usize) -> Option<()> {
let is_success = unsafe {
let env = android::attach_jni_env();
ndk_utils::call_bool_method!(env, android::ACTIVITY, "focus", "(I)Z", id as i32)
};
if is_success == 0u8 {
None
} else {
Some(())
}
}
pub fn unfocus(id: usize) -> Option<()> {
let is_success = unsafe {
let env = android::attach_jni_env();
ndk_utils::call_bool_method!(env, android::ACTIVITY, "unfocus", "(I)Z", id as i32)
};
if is_success == 0u8 {
None
} else {
Some(())
}
}
pub fn set_text(id: usize, text: &str) -> Option<()> {
let ctext = std::ffi::CString::new(text).unwrap();
let is_success = unsafe {
let env = android::attach_jni_env();
let new_string_utf = (**env).NewStringUTF.unwrap();
let jtext = new_string_utf(env, ctext.as_ptr());
let delete_local_ref = (**env).DeleteLocalRef.unwrap();
let res = ndk_utils::call_bool_method!(
env,
android::ACTIVITY,
"setText",
"(ILjava/lang/String;)Z",
id as i32,
jtext
);
delete_local_ref(env, jtext);
res
};
if is_success == 0u8 {
None
} else {
Some(())
}
}
pub fn set_selection(id: usize, select_start: usize, select_end: usize) -> Option<()> {
//trace!(target: "android", "set_selection({id}, {select_start}, {select_end})");
let is_success = unsafe {
let env = android::attach_jni_env();
ndk_utils::call_bool_method!(
env,
android::ACTIVITY,
"setSelection",
"(III)Z",
id as i32,
select_start as i32,
select_end as i32
)
};
if is_success == 0u8 {
None
} else {
Some(())
}
}
pub fn commit_text(id: usize, text: &str) -> Option<()> {
let ctext = std::ffi::CString::new(text).unwrap();
let is_success = unsafe {
let env = android::attach_jni_env();
let new_string_utf = (**env).NewStringUTF.unwrap();
let delete_local_ref = (**env).DeleteLocalRef.unwrap();
let jtext = new_string_utf(env, ctext.as_ptr());
let res = ndk_utils::call_bool_method!(
env,
android::ACTIVITY,
"commitText",
"(ILjava/lang/String;)Z",
id as i32,
jtext
);
delete_local_ref(env, jtext);
res
};
if is_success == 0u8 {
None
} else {
Some(())
}
}
pub struct Editable {
pub buffer: String,
pub select_start: usize,
pub select_end: usize,
pub compose_start: Option<usize>,
pub compose_end: Option<usize>,
}
pub fn get_editable(id: usize) -> Option<Editable> {
//trace!(target: "android", "get_editable({id})");
unsafe {
let env = android::attach_jni_env();
let input_view = ndk_utils::call_object_method!(
env,
android::ACTIVITY,
"getInputView",
"(I)Lautosuggest/InvisibleInputView;",
id as i32
);
if input_view.is_null() {
return None
}
let buffer =
ndk_utils::call_object_method!(env, input_view, "rawText", "()Ljava/lang/String;");
assert!(!buffer.is_null());
let buffer = ndk_utils::get_utf_str!(env, buffer).to_string();
let select_start = ndk_utils::call_int_method!(env, input_view, "getSelectionStart", "()I");
let select_end = ndk_utils::call_int_method!(env, input_view, "getSelectionEnd", "()I");
let compose_start = ndk_utils::call_int_method!(env, input_view, "getComposeStart", "()I");
let compose_end = ndk_utils::call_int_method!(env, input_view, "getComposeEnd", "()I");
assert!(select_start >= 0);
assert!(select_end >= 0);
assert!(compose_start >= 0 || compose_start == compose_end);
assert!(compose_start <= compose_end);
Some(Editable {
buffer,
select_start: select_start as usize,
select_end: select_end as usize,
compose_start: if compose_start < 0 { None } else { Some(compose_start as usize) },
compose_end: if compose_end < 0 { None } else { Some(compose_end as usize) },
})
}
}
pub fn get_appdata_path() -> PathBuf {
call_mainactivity_str_method!("getAppDataPath").into()
}

View File

@@ -0,0 +1,374 @@
/* This file is part of DarkFi (https://dark.fi)
*
* Copyright (C) 2020-2025 Dyne.org foundation
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use miniquad::native::android::{self, ndk_sys, ndk_utils::*};
use parking_lot::{Mutex as SyncMutex, RwLock};
use std::{
ffi::CString,
sync::{Arc, OnceLock},
};
use super::{AndroidTextInputState, SharedStatePtr};
macro_rules! t { ($($arg:tt)*) => { trace!(target: "android::textinput::gametextinput", $($arg)*); } }
macro_rules! w { ($($arg:tt)*) => { warn!(target: "android::textinput::gametextinput", $($arg)*); } }
pub const SPAN_UNDEFINED: i32 = -1;
/// Global GameTextInput instance for JNI bridge
///
/// Single global instance since only ONE editor is active at a time.
pub static GAME_TEXT_INPUT: OnceLock<GameTextInput> = OnceLock::new();
struct StateClassInfo {
text: ndk_sys::jfieldID,
selection_start: ndk_sys::jfieldID,
selection_end: ndk_sys::jfieldID,
composing_region_start: ndk_sys::jfieldID,
composing_region_end: ndk_sys::jfieldID,
}
pub struct GameTextInput {
state: SyncMutex<Option<SharedStatePtr>>,
input_connection: RwLock<Option<ndk_sys::jobject>>,
input_connection_class: ndk_sys::jclass,
state_class: ndk_sys::jclass,
set_soft_keyboard_active_method: ndk_sys::jmethodID,
restart_input_method: ndk_sys::jmethodID,
state_constructor: ndk_sys::jmethodID,
state_class_info: StateClassInfo,
}
impl GameTextInput {
pub fn new() -> Self {
unsafe {
let env = android::attach_jni_env();
let find_class = (**env).FindClass.unwrap();
let state_class_name = b"textinput/State\0";
let input_connection_class_name = b"textinput/InputConnection\0";
let state_java_class = find_class(env, state_class_name.as_ptr() as _);
let input_connection_class = find_class(env, input_connection_class_name.as_ptr() as _);
let input_connection_class =
new_global_ref!(env, input_connection_class) as ndk_sys::jclass;
let get_method_id = (**env).GetMethodID.unwrap();
let set_state_sig = b"(Ltextinput/State;)V\0";
let _input_connection_set_state_method = get_method_id(
env,
input_connection_class,
b"setState\0".as_ptr() as _,
set_state_sig.as_ptr() as _,
);
let set_soft_keyboard_active_sig = b"(ZI)V\0";
let set_soft_keyboard_active_method = get_method_id(
env,
input_connection_class,
b"setSoftKeyboardActive\0".as_ptr() as _,
set_soft_keyboard_active_sig.as_ptr() as _,
);
let restart_input_sig = b"()V\0";
let restart_input_method = get_method_id(
env,
input_connection_class,
b"restartInput\0".as_ptr() as _,
restart_input_sig.as_ptr() as _,
);
let state_class = new_global_ref!(env, state_java_class) as ndk_sys::jclass;
let get_field_id = (**env).GetFieldID.unwrap();
let text_field = get_field_id(
env,
state_class,
b"text\0".as_ptr() as _,
b"Ljava/lang/String;\0".as_ptr() as _,
);
let selection_start_field = get_field_id(
env,
state_class,
b"selectionStart\0".as_ptr() as _,
b"I\0".as_ptr() as _,
);
let selection_end_field = get_field_id(
env,
state_class,
b"selectionEnd\0".as_ptr() as _,
b"I\0".as_ptr() as _,
);
let composing_region_start_field = get_field_id(
env,
state_class,
b"composingRegionStart\0".as_ptr() as _,
b"I\0".as_ptr() as _,
);
let composing_region_end_field = get_field_id(
env,
state_class,
b"composingRegionEnd\0".as_ptr() as _,
b"I\0".as_ptr() as _,
);
let constructor_sig = b"(Ljava/lang/String;IIII)V\0";
let state_constructor = get_method_id(
env,
state_class,
b"<init>\0".as_ptr() as _,
constructor_sig.as_ptr() as _,
);
let state_class_info = StateClassInfo {
text: text_field,
selection_start: selection_start_field,
selection_end: selection_end_field,
composing_region_start: composing_region_start_field,
composing_region_end: composing_region_end_field,
};
Self {
state: SyncMutex::new(None),
input_connection: RwLock::new(None),
input_connection_class,
state_class,
set_soft_keyboard_active_method,
restart_input_method,
state_constructor,
state_class_info,
}
}
}
pub fn focus(&self, state: SharedStatePtr) {
// Replace old focus
let mut active_focus = self.state.lock();
if let Some(old_focus) = &mut *active_focus {
// Mark old focused state as no longer active
old_focus.lock().is_active = false;
}
*active_focus = Some(state.clone());
drop(active_focus);
let mut new_focus = state.lock();
// Mark new state as active
new_focus.is_active = true;
let new_state = new_focus.state.clone();
drop(new_focus);
// Push changes to the Java side
self.push_update(&new_state);
}
pub fn push_update(&self, state: &AndroidTextInputState) {
let Some(input_connection) = *self.input_connection.read() else {
w!("push_update() - no input_connection set");
return
};
unsafe {
let env = android::attach_jni_env();
let jstate = self.state_to_java(state);
call_void_method!(env, input_connection, "setState", "(Ltextinput/State;)V", jstate);
let delete_local_ref = (**env).DeleteLocalRef.unwrap();
delete_local_ref(env, jstate);
}
}
pub fn set_select(&self, start: i32, end: i32) -> Result<(), ()> {
let Some(input_connection) = *self.input_connection.read() else {
w!("push_update() - no input_connection set");
return Err(())
};
let is_success = unsafe {
let env = android::attach_jni_env();
call_bool_method!(env, input_connection, "setSelection", "(II)Z", start, end)
};
if is_success == 0u8 {
return Err(())
}
Ok(())
}
pub fn set_input_connection(&self, input_connection: ndk_sys::jobject) {
unsafe {
let env = android::attach_jni_env();
let mut ic = self.input_connection.write();
if let Some(old_ref) = *ic {
let delete_global_ref = (**env).DeleteGlobalRef.unwrap();
delete_global_ref(env, old_ref);
}
*ic = Some(new_global_ref!(env, input_connection));
}
}
pub fn process_event(&self, event_state: ndk_sys::jobject) {
let state = self.state_from_java(event_state);
t!("IME event: {state:?}");
let Some(shared) = &*self.state.lock() else {
w!("process_event() - no shared state set");
return
};
let mut inner = shared.lock();
inner.state = state.clone();
let _ = inner.sender.try_send(state);
}
pub fn show_ime(&self, flags: u32) {
let Some(input_connection) = *self.input_connection.read() else {
w!("show_ime() - no input_connection set");
return
};
unsafe {
let env = android::attach_jni_env();
let call_void_method = (**env).CallVoidMethod.unwrap();
call_void_method(
env,
input_connection,
self.set_soft_keyboard_active_method,
1, // active: true
flags as ndk_sys::jint,
);
}
}
pub fn hide_ime(&self, flags: u32) {
let Some(input_connection) = *self.input_connection.read() else {
w!("hide_ime() - no input_connection set");
return
};
unsafe {
let env = android::attach_jni_env();
let call_void_method = (**env).CallVoidMethod.unwrap();
call_void_method(
env,
input_connection,
self.set_soft_keyboard_active_method,
0, // active: false
flags as ndk_sys::jint,
);
}
}
pub fn restart_input(&self) {
let Some(input_connection) = *self.input_connection.read() else {
w!("restart_input() - no input_connection set");
return
};
unsafe {
let env = android::attach_jni_env();
let call_void_method = (**env).CallVoidMethod.unwrap();
call_void_method(env, input_connection, self.restart_input_method);
}
}
fn state_to_java(&self, state: &AndroidTextInputState) -> ndk_sys::jobject {
unsafe {
let env = android::attach_jni_env();
let new_string_utf = (**env).NewStringUTF.unwrap();
let text_str = CString::new(state.text.as_str()).unwrap();
let jtext = new_string_utf(env, text_str.as_ptr());
let new_object = (**env).NewObject.unwrap();
let (compose_start, compose_end) = match state.compose {
Some((start, end)) => (start as i32, end as i32),
None => (SPAN_UNDEFINED, SPAN_UNDEFINED),
};
let jobj = new_object(
env,
self.state_class,
self.state_constructor,
jtext,
state.select.0 as i32,
state.select.1 as i32,
compose_start,
compose_end,
);
let delete_local_ref = (**env).DeleteLocalRef.unwrap();
delete_local_ref(env, jtext);
jobj
}
}
fn state_from_java(&self, event_state: ndk_sys::jobject) -> AndroidTextInputState {
unsafe {
let env = android::attach_jni_env();
let get_object_field = (**env).GetObjectField.unwrap();
let jtext =
get_object_field(env, event_state, self.state_class_info.text) as ndk_sys::jstring;
let text = get_utf_str!(env, jtext);
let get_int_field = (**env).GetIntField.unwrap();
let select_start =
get_int_field(env, event_state, self.state_class_info.selection_start);
let select_end = get_int_field(env, event_state, self.state_class_info.selection_end);
let compose_start =
get_int_field(env, event_state, self.state_class_info.composing_region_start);
let compose_end =
get_int_field(env, event_state, self.state_class_info.composing_region_end);
let delete_local_ref = (**env).DeleteLocalRef.unwrap();
delete_local_ref(env, jtext);
let compose = if compose_start >= 0 {
Some((compose_start as usize, compose_end as usize))
} else {
assert!(compose_end < 0);
None
};
AndroidTextInputState {
text,
select: (select_start as usize, select_end as usize),
compose,
}
}
}
}
impl Drop for GameTextInput {
fn drop(&mut self) {
unsafe {
let env = android::attach_jni_env();
let delete_global_ref = (**env).DeleteGlobalRef.unwrap();
if self.input_connection_class != std::ptr::null_mut() {
delete_global_ref(env, self.input_connection_class);
}
if self.state_class != std::ptr::null_mut() {
delete_global_ref(env, self.state_class);
}
if let Some(input_connection) = *self.input_connection.read() {
delete_global_ref(env, input_connection);
}
}
}
}
unsafe impl Send for GameTextInput {}
unsafe impl Sync for GameTextInput {}

View File

@@ -0,0 +1,55 @@
/* This file is part of DarkFi (https://dark.fi)
*
* Copyright (C) 2020-2025 Dyne.org foundation
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use miniquad::native::android::ndk_sys;
use super::gametextinput::{GameTextInput, GAME_TEXT_INPUT};
/// Set the InputConnection for GameTextInput (called from Java)
///
/// This follows the official Android GameTextInput integration pattern:
/// https://developer.android.com/games/agdk/add-support-for-text-input
///
/// Called from MainActivity when the InputConnection is created. It passes
/// the Java InputConnection object to the native GameTextInput library.
#[no_mangle]
pub extern "C" fn Java_darkfi_darkfi_1app_MainActivity_setInputConnectionNative(
_env: *mut ndk_sys::JNIEnv,
_class: ndk_sys::jclass,
input_connection: ndk_sys::jobject,
) {
debug!(target: "android::textinput::jni", "Setting input connection");
// Initialize GameTextInput on first call
let gti = GAME_TEXT_INPUT.get_or_init(|| GameTextInput::new());
gti.set_input_connection(input_connection);
}
/// Process IME state event from Java Listener.stateChanged()
///
/// This follows the official Android GameTextInput integration pattern.
/// Called from the Java InputConnection's Listener whenever the IME sends
/// a state change (text typed, cursor moved, etc.).
#[no_mangle]
pub extern "C" fn Java_darkfi_darkfi_1app_MainActivity_onTextInputEventNative(
_env: *mut ndk_sys::JNIEnv,
_class: ndk_sys::jclass,
soft_keyboard_event: ndk_sys::jobject,
) {
let gti = GAME_TEXT_INPUT.get().unwrap();
gti.process_event(soft_keyboard_event);
}

View File

@@ -0,0 +1,108 @@
/* This file is part of DarkFi (https://dark.fi)
*
* Copyright (C) 2020-2025 Dyne.org foundation
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use async_channel::Sender as AsyncSender;
use parking_lot::Mutex as SyncMutex;
use std::sync::Arc;
mod gametextinput;
mod jni;
use gametextinput::{GameTextInput, GAME_TEXT_INPUT};
macro_rules! t { ($($arg:tt)*) => { trace!(target: "android::textinput", $($arg)*); } }
// Text input state exposed to the rest of the app
#[derive(Debug, Clone, Default)]
pub struct AndroidTextInputState {
pub text: String,
pub select: (usize, usize),
pub compose: Option<(usize, usize)>,
}
struct SharedState {
state: AndroidTextInputState,
/// Used so we know whether to also update the GameTextInput in Android.
/// We should only do so when active.
is_active: bool,
sender: AsyncSender<AndroidTextInputState>,
}
impl SharedState {
fn new(sender: AsyncSender<AndroidTextInputState>) -> Self {
Self { state: Default::default(), is_active: false, sender }
}
}
pub(self) type SharedStatePtr = Arc<SyncMutex<SharedState>>;
pub struct AndroidTextInput {
state: SharedStatePtr,
}
impl AndroidTextInput {
pub fn new(sender: AsyncSender<AndroidTextInputState>) -> Self {
Self { state: Arc::new(SyncMutex::new(SharedState::new(sender))) }
}
pub fn show(&self) {
t!("show IME");
let gti = GAME_TEXT_INPUT.get().unwrap();
gti.focus(self.state.clone());
gti.show_ime(0);
}
pub fn hide(&self) {
t!("hide IME");
let gti = GAME_TEXT_INPUT.get().unwrap();
gti.hide_ime(0);
}
pub fn set_state(&self, state: AndroidTextInputState) {
t!("set_state({state:?})");
// Always update our own state.
let mut ours = self.state.lock();
ours.state = state.clone();
let is_active = ours.is_active;
drop(ours);
// Only update java state when this input is active
if is_active {
let gti = GAME_TEXT_INPUT.get().unwrap();
gti.push_update(&state);
}
}
pub fn set_select(&self, select_start: usize, select_end: usize) {
//t!("set_select({select_start}, {select_end})");
// Always update our own state.
let mut ours = self.state.lock();
let state = &mut ours.state;
assert!(select_start <= state.text.len());
assert!(select_end <= state.text.len());
state.select = (select_start, select_end);
let is_active = ours.is_active;
drop(ours);
// Only update java state when this input is active
if is_active {
let gti = GAME_TEXT_INPUT.get().unwrap();
gti.set_select(select_start as i32, select_end as i32).unwrap();
}
}
}

View File

@@ -0,0 +1,38 @@
/* This file is part of DarkFi (https://dark.fi)
*
* Copyright (C) 2020-2025 Dyne.org foundation
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use miniquad::native::android::ndk_sys;
/// Check for pending Java exceptions and log them
///
/// This function should be called after any JNI call that might throw an exception.
/// It will use ExceptionDescribe to print the exception to logcat, then clear it.
unsafe fn check_except(env: *mut ndk_sys::JNIEnv, context: &str) {
let exception_check = (**env).ExceptionCheck.unwrap();
if exception_check(env) != 0 {
// Use ExceptionDescribe to print the exception stack trace to logcat
// This is safe to call even with a pending exception and handles StackOverflowError gracefully
let exception_describe = (**env).ExceptionDescribe.unwrap();
exception_describe(env);
let exception_clear = (**env).ExceptionClear.unwrap();
exception_clear(env);
panic!("Java exception detected in {context}");
}
}

View File

@@ -1559,13 +1559,19 @@ pub async fn make(
let atom = &mut render_api.make_guard(gfxtag!("edit select task"));
if editz_select_text.is_null(0).unwrap() {
info!(target: "app::chat", "selection changed: null");
actions_is_visible.set(atom, false);
pasta_is_visible2.set(atom, false);
// Avoid triggering unecessary redraws
if actions_is_visible.get() {
actions_is_visible.set(atom, false);
pasta_is_visible2.set(atom, false);
}
} else {
let select_text = editz_select_text.get_str(0).unwrap();
info!(target: "app::chat", "selection changed: {select_text}");
actions_is_visible.set(atom, true);
pasta_is_visible2.set(atom, false);
// Avoid triggering unecessary redraws
if !actions_is_visible.get() {
actions_is_visible.set(atom, true);
pasta_is_visible2.set(atom, false);
}
}
}
});

View File

@@ -27,16 +27,6 @@ use std::sync::{Arc, OnceLock};
#[macro_use]
extern crate tracing;
#[derive(Debug)]
pub enum AndroidSuggestEvent {
Init,
CreateInputConnect,
Compose { text: String, cursor_pos: i32, is_commit: bool },
ComposeRegion { start: usize, end: usize },
FinishCompose,
DeleteSurroundingText { left: usize, right: usize },
}
#[cfg(target_os = "android")]
mod android;
mod app;

View File

@@ -17,41 +17,23 @@
*/
use crate::{
android,
android::{
self,
textinput::{AndroidTextInput, AndroidTextInputState},
},
gfx::Point,
mesh::Color,
prop::{PropertyAtomicGuard, PropertyColor, PropertyFloat32, PropertyStr},
text2::{TextContext, TEXT_CTX},
AndroidSuggestEvent,
};
use std::{
cmp::{max, min},
sync::atomic::{AtomicBool, Ordering},
};
use std::cmp::{max, min};
macro_rules! t { ($($arg:tt)*) => { trace!(target: "text::editor::android", $($arg)*); } }
// You must be careful working with string indexes in Java. They are UTF16 string indexs, not UTF8
fn char16_to_byte_index(s: &str, char_idx: usize) -> Option<usize> {
let utf16_data: Vec<_> = s.encode_utf16().take(char_idx).collect();
let prestr = String::from_utf16(&utf16_data).ok()?;
Some(prestr.len())
}
fn byte_to_char16_index(s: &str, byte_idx: usize) -> Option<usize> {
if byte_idx > s.len() || !s.is_char_boundary(byte_idx) {
return None
}
Some(s[..byte_idx].encode_utf16().count())
}
pub struct Editor {
pub composer_id: usize,
pub recvr: async_channel::Receiver<AndroidSuggestEvent>,
is_init: bool,
is_setup: bool,
/// We cannot receive focus until `AndroidSuggestEvent::Init` has finished.
/// We use this flag to delay calling `android::focus()` until the init has completed.
is_focus_req: AtomicBool,
input: AndroidTextInput,
pub state: AndroidTextInputState,
pub recvr: async_channel::Receiver<AndroidTextInputState>,
layout: parley::Layout<Color>,
width: Option<f32>,
@@ -72,15 +54,11 @@ impl Editor {
lineheight: PropertyFloat32,
) -> Self {
let (sender, recvr) = async_channel::unbounded();
let composer_id = android::create_composer(sender);
t!("Created composer [{composer_id}]");
let input = AndroidTextInput::new(sender);
Self {
composer_id,
input,
state: Default::default(),
recvr,
is_init: false,
is_setup: false,
is_focus_req: AtomicBool::new(false),
layout: Default::default(),
width: None,
@@ -93,65 +71,33 @@ impl Editor {
}
}
/// Called on `AndroidSuggestEvent::Init` after the View has been added to the main hierarchy
/// and is ready to receive commands such as focus.
pub fn init(&mut self) {
self.is_init = true;
// Perform any focus requests.
let is_focus_req = self.is_focus_req.swap(false, Ordering::SeqCst);
if is_focus_req {
android::focus(self.composer_id).unwrap();
}
//android::focus(self.composer_id).unwrap();
//let atxt = "A berry is small juicy 😊 pulpy and edible.";
//let atxt = "A berry is a small, pulpy, and often edible fruit. Typically, berries are juicy, rounded, brightly colored, sweet, sour or tart, and do not have a stone or pit, although many pips or seeds may be present. Common examples of berries in the culinary sense are strawberries, raspberries, blueberries, blackberries, white currants, blackcurrants, and redcurrants. In Britain, soft fruit is a horticultural term for such fruits. The common usage of the term berry is different from the scientific or botanical definition of a berry, which refers to a fruit produced from the ovary of a single flower where the outer layer of the ovary wall develops into an edible fleshy portion (pericarp). The botanical definition includes many fruits that are not commonly known or referred to as berries, such as grapes, tomatoes, cucumbers, eggplants, bananas, and chili peppers.";
//let atxt = "small berry terry";
//android::set_text(self.composer_id, atxt);
//self.set_selection(2, 7);
// Call this after:
//self.on_buffer_changed(&mut PropertyAtomicGuard::none()).await;
}
/// Called on `AndroidSuggestEvent::CreateInputConnect`, which only happens after the View
/// is focused for the first time.
pub fn setup(&mut self) {
assert!(self.is_init);
self.is_setup = true;
assert!(self.composer_id != usize::MAX);
t!("Initialized composer [{}]", self.composer_id);
}
pub async fn on_text_prop_changed(&mut self) {
// Get modified text property
let txt = self.text.get();
// Update Android text buffer
android::set_text(self.composer_id, &txt);
assert_eq!(android::get_editable(self.composer_id).unwrap().buffer, txt);
// Update GameTextInput state
self.state.text = self.text.get();
self.state.select = (0, 0);
self.state.compose = None;
self.input.set_state(self.state.clone());
// Refresh our layout
self.refresh().await;
}
pub async fn on_buffer_changed(&mut self, atom: &mut PropertyAtomicGuard) {
// Refresh the layout using the Android buffer
self.refresh().await;
// Update the text attribute
let edit = android::get_editable(self.composer_id).unwrap();
self.text.set(atom, &edit.buffer);
}
/// Can only be called after AndroidSuggestEvent::Init.
pub fn focus(&self) {
// We're not yet ready to receive focus
if !self.is_init {
self.is_focus_req.store(true, Ordering::SeqCst);
// Only refresh layout if text content actually changed
// Avoid triggering expensive recomputes of layout and property tree.
let old_text = self.text.get();
if old_text == self.state.text {
return
}
android::focus(self.composer_id).unwrap();
self.refresh().await;
// Update the text attribute
self.text.set(atom, &self.state.text);
}
pub fn unfocus(&self) {
android::unfocus(self.composer_id).unwrap();
pub fn focus(&mut self) {
self.input.show();
}
pub fn unfocus(&mut self) {
self.input.hide();
}
pub async fn refresh(&mut self) {
@@ -160,20 +106,14 @@ impl Editor {
let window_scale = self.window_scale.get();
let lineheight = self.lineheight.get();
let edit = android::get_editable(self.composer_id).unwrap();
let mut underlines = vec![];
if let Some(compose_start) = edit.compose_start {
let compose_end = edit.compose_end.unwrap();
let compose_start = char16_to_byte_index(&edit.buffer, compose_start).unwrap();
let compose_end = char16_to_byte_index(&edit.buffer, compose_end).unwrap();
if let Some((compose_start, compose_end)) = self.state.compose {
underlines.push(compose_start..compose_end);
}
let mut txt_ctx = TEXT_CTX.get().await;
self.layout = txt_ctx.make_layout(
&edit.buffer,
&self.state.text,
text_color,
font_size,
lineheight,
@@ -187,14 +127,15 @@ impl Editor {
&self.layout
}
pub fn move_to_pos(&self, pos: Point) {
pub fn move_to_pos(&mut self, pos: Point) {
let cursor = parley::Cursor::from_point(&self.layout, pos.x, pos.y);
let edit = android::get_editable(self.composer_id).unwrap();
let cursor_idx = cursor.index();
let pos = byte_to_char16_index(&edit.buffer, cursor_idx).unwrap();
t!(" {cursor_idx} => {pos}");
android::set_selection(self.composer_id, pos, pos);
t!(" move_to_pos: {cursor_idx}");
assert!(cursor_idx <= self.state.text.len());
assert_eq!(self.state.text, self.text.get());
self.state.select = (cursor_idx, cursor_idx);
self.state.compose = None;
self.input.set_select(cursor_idx, cursor_idx);
}
pub async fn select_word_at_point(&mut self, pos: Point) {
@@ -206,29 +147,29 @@ impl Editor {
pub fn get_cursor_pos(&self) -> Point {
let lineheight = self.lineheight.get();
let edit = android::get_editable(self.composer_id).unwrap();
let cursor_idx = self.state.select.0;
let cursor_byte_idx = char16_to_byte_index(&edit.buffer, edit.select_start).unwrap();
let cursor = if cursor_byte_idx >= edit.buffer.len() {
let cursor = if cursor_idx >= self.state.text.len() {
parley::Cursor::from_byte_index(
&self.layout,
edit.buffer.len(),
self.state.text.len(),
parley::Affinity::Upstream,
)
} else {
parley::Cursor::from_byte_index(
&self.layout,
cursor_byte_idx,
parley::Affinity::Downstream,
)
parley::Cursor::from_byte_index(&self.layout, cursor_idx, parley::Affinity::Downstream)
};
let cursor_rect = cursor.geometry(&self.layout, lineheight);
Point::new(cursor_rect.x0 as f32, cursor_rect.y0 as f32)
}
pub async fn insert(&mut self, txt: &str, atom: &mut PropertyAtomicGuard) {
android::commit_text(self.composer_id, txt);
// TODO: need to verify this is correct
// Insert text by updating the state
self.state.text.push_str(txt);
let cursor_idx = self.state.text.len();
self.state.select = (cursor_idx, cursor_idx);
self.state.compose = None;
self.input.set_state(self.state.clone());
self.on_buffer_changed(atom).await;
}
@@ -250,26 +191,21 @@ impl Editor {
}
pub fn selected_text(&self) -> Option<String> {
let edit = android::get_editable(self.composer_id).unwrap();
if edit.select_start == edit.select_end {
let (start, end) = (self.state.select.0, self.state.select.1);
if start == end {
return None
}
let anchor = char16_to_byte_index(&edit.buffer, edit.select_start).unwrap();
let index = char16_to_byte_index(&edit.buffer, edit.select_end).unwrap();
let (start, end) = (min(anchor, index), max(anchor, index));
Some(edit.buffer[start..end].to_string())
let (start, end) = (min(start, end), max(start, end));
Some(self.state.text[start..end].to_string())
}
pub fn selection(&self, side: isize) -> parley::Selection {
assert!(side.abs() == 1);
let edit = android::get_editable(self.composer_id).unwrap();
let select_start = char16_to_byte_index(&edit.buffer, edit.select_start).unwrap();
let select_end = char16_to_byte_index(&edit.buffer, edit.select_end).unwrap();
//t!("selection() -> ({select_start}, {select_end})");
//t!("selection({side}) [state={:?}]", self.state);
let (start, end) = (self.state.select.0, self.state.select.1);
let (anchor, focus) = match side {
-1 => (select_end, select_start),
1 => (select_start, select_end),
-1 => (end, start),
1 => (start, end),
_ => panic!(),
};
@@ -281,16 +217,16 @@ impl Editor {
parley::Selection::new(anchor, focus)
}
pub async fn set_selection(&mut self, select_start: usize, select_end: usize) {
//t!("set_selection({select_start}, {select_end})");
let edit = android::get_editable(self.composer_id).unwrap();
let select_start = byte_to_char16_index(&edit.buffer, select_start).unwrap();
let select_end = byte_to_char16_index(&edit.buffer, select_end).unwrap();
android::set_selection(self.composer_id, select_start, select_end);
assert!(select_start <= self.state.text.len());
assert!(select_end <= self.state.text.len());
assert_eq!(self.state.text, self.text.get());
self.state.select = (select_start, select_end);
self.state.compose = None;
self.input.set_select(select_start, select_end);
}
#[allow(dead_code)]
pub fn buffer(&self) -> String {
let edit = android::get_editable(self.composer_id).unwrap();
edit.buffer
self.state.text.clone()
}
}

View File

@@ -1003,7 +1003,7 @@ impl UIObject for ChatView {
}
let rect = self.rect.get();
t!("handle_touch({phase:?}, {id},{id}, {touch_pos:?})");
//t!("handle_touch({phase:?}, {id},{id}, {touch_pos:?})");
let atom = &mut self.render_api.make_guard(gfxtag!("ChatView::handle_touch"));
let touch_y = touch_pos.y;

View File

@@ -37,7 +37,7 @@ use std::{
use tracing::instrument;
#[cfg(target_os = "android")]
use crate::AndroidSuggestEvent;
use crate::android::textinput::AndroidTextInputState;
use crate::{
gfx::{gfxtag, DrawCall, DrawInstruction, DrawMesh, Point, Rectangle, RenderApi, Vertex},
mesh::MeshBuilder,
@@ -857,7 +857,7 @@ impl BaseEdit {
}
true
}
async fn handle_touch_end(&self, atom: &mut PropertyAtomicGuard, mut touch_pos: Point) -> bool {
async fn handle_touch_end(&self, mut touch_pos: Point) -> bool {
//t!("handle_touch_end({touch_pos:?})");
self.abs_to_local(&mut touch_pos);
@@ -865,6 +865,7 @@ impl BaseEdit {
match state {
TouchStateAction::Inactive => return false,
TouchStateAction::Started { pos: _, instant: _ } | TouchStateAction::SetCursorPos => {
let atom = &mut self.render_api.make_guard(gfxtag!("BaseEdit::handle_touch_end"));
self.touch_set_cursor_pos(atom, touch_pos).await;
self.redraw(atom).await;
}
@@ -980,7 +981,7 @@ impl BaseEdit {
editor.selected_text()
};
//d!("Select {seltext:?} from {clip_mouse_pos:?} (unclipped: {mouse_pos:?}) to ({sel_start}, {sel_end})");
//d!("Select {seltext:?} from {clip_mouse_pos:?} (unclipped: {mouse_pos:?})");
// Android editor impl detail: selection disappears when anchor == index
// But we disallow this so it should never happen. Just making a note of it here.
@@ -1047,6 +1048,7 @@ impl BaseEdit {
}
async fn redraw_select(&self, batch_id: BatchGuardId) {
//t!("redraw_select");
let sel_instrs = self.regen_select_mesh().await;
let phone_sel_instrs = self.regen_phone_select_handle_mesh().await;
let draw_calls = vec![
@@ -1271,7 +1273,7 @@ impl BaseEdit {
panic!("self destroyed before insert_text_method_task was stopped!");
};
let editor = self_.lock_editor().await;
let mut editor = self_.lock_editor().await;
editor.focus();
true
}
@@ -1290,59 +1292,59 @@ impl BaseEdit {
panic!("self destroyed before insert_text_method_task was stopped!");
};
let editor = self_.lock_editor().await;
let mut editor = self_.lock_editor().await;
editor.unfocus();
true
}
#[cfg(target_os = "android")]
async fn handle_android_event(&self, ev: AndroidSuggestEvent) {
async fn handle_android_event(&self, state: AndroidTextInputState) {
if !self.is_active.get() {
return
}
t!("handle_android_event({ev:?})");
t!("handle_android_event({state:?})");
let atom = &mut self.render_api.make_guard(gfxtag!("BaseEdit::handle_android_event"));
match ev {
AndroidSuggestEvent::Init => {
let mut editor = self.lock_editor().await;
editor.init();
// For debugging select, enable these and set a selection in the editor.
//self.is_phone_select.store(true, Ordering::Relaxed);
//self.hide_cursor.store(true, Ordering::Relaxed);
// Debug code if we set text in editor.init()
//editor.on_buffer_changed(&mut PropertyAtomicGuard::none()).await;
return
}
AndroidSuggestEvent::CreateInputConnect => {
let mut editor = self.lock_editor().await;
editor.setup();
}
// Destructive text edits
AndroidSuggestEvent::ComposeRegion { .. } |
AndroidSuggestEvent::Compose { .. } |
AndroidSuggestEvent::DeleteSurroundingText { .. } => {
// Any editing will collapse selections
self.finish_select(atom);
let mut editor = self.lock_editor().await;
// Diff old and new state so we know what changed
let is_text_changed = editor.state.text != state.text;
let is_select_changed = editor.state.select != state.select;
let is_compose_changed = editor.state.compose != state.compose;
editor.state = state;
editor.on_buffer_changed(atom).await;
drop(editor);
let mut editor = self.lock_editor().await;
editor.on_buffer_changed(atom).await;
drop(editor);
self.eval_rect().await;
self.behave.apply_cursor_scroll().await;
}
AndroidSuggestEvent::FinishCompose => {
let mut editor = self.lock_editor().await;
editor.on_buffer_changed(atom).await;
}
// Nothing changed. Just return.
if !is_text_changed && !is_select_changed && !is_compose_changed {
//t!("Skipping update since nothing changed");
return
}
//t!("is_text_changed={is_text_changed}, is_select_changed={is_select_changed}, is_compose_changed={is_compose_changed}");
// Only redraw once we have the parent_rect
// Can happen when we receive an Android event before the canvas is ready
if self.parent_rect.lock().is_some() {
if self.parent_rect.lock().is_none() {
return
}
// Not sure what to do if only compose changes lol
// For now just ignore it.
// Text changed - finish any active selection
if is_text_changed {
self.eval_rect().await;
self.behave.apply_cursor_scroll().await;
self.pause_blinking();
//assert!(state.text != self.text.get());
self.finish_select(atom);
self.redraw(atom).await;
} else if is_select_changed {
// Redrawing the entire text just for select changes is expensive
self.redraw_cursor(atom.batch_id).await;
//t!("handle_android_event calling redraw_select");
self.redraw_select(atom.batch_id).await;
}
}
}
@@ -1364,13 +1366,18 @@ impl UIObject for BaseEdit {
fn init(&self) {
let mut guard = self.editor.lock_blocking();
assert!(guard.is_none());
*guard = Some(Editor::new(
let editor = Editor::new(
self.text.clone(),
self.font_size.clone(),
self.text_color.clone(),
self.window_scale.clone(),
self.lineheight.clone(),
));
);
// For Android you can do this:
//let atom = &mut PropertyAtomicGuard::none();
//self.text.set(atom, "the quick brown fox jumped over the");
//smol::block_on(editor.on_text_prop_changed());
*guard = Some(editor);
}
async fn start(self: Arc<Self>, ex: ExecutorPtr) {
@@ -1762,12 +1769,10 @@ impl UIObject for BaseEdit {
return false
}
let atom = &mut self.render_api.make_guard(gfxtag!("BaseEdit::handle_touch"));
match phase {
TouchPhase::Started => self.handle_touch_start(touch_pos).await,
TouchPhase::Moved => self.handle_touch_move(touch_pos).await,
TouchPhase::Ended => self.handle_touch_end(atom, touch_pos).await,
TouchPhase::Ended => self.handle_touch_end(touch_pos).await,
TouchPhase::Cancelled => false,
}
}

View File

@@ -65,8 +65,10 @@ macro_rules! t { ($($arg:tt)*) => { trace!(target: "scene::on_modify", $($arg)*)
pub trait UIObject: Sync {
fn priority(&self) -> u32;
/// Called after schema and scenegraph is init but before miniquad starts.
fn init(&self) {}
/// Done after miniquad has started and the first window draw has been done.
async fn start(self: Arc<Self>, _ex: ExecutorPtr) {}
/// Clear all buffers and caches