app: completely rework text editing subsystem. initial draft version

This commit is contained in:
darkfi
2025-04-15 13:07:31 +02:00
parent 396a129d53
commit d27f5dfe4e
28 changed files with 1788 additions and 1250 deletions

32
bin/app/Cargo.lock generated
View File

@@ -1304,6 +1304,7 @@ dependencies = [
"miniquad",
"parking_lot 0.12.3",
"parley",
"peniko",
"rand",
"regex",
"rusqlite",
@@ -1311,11 +1312,11 @@ dependencies = [
"simplelog",
"sled-overlay",
"smol",
"swash",
"swash 0.2.1",
"thiserror 2.0.12",
"tor-dirmgr 0.26.0",
"url",
"zeno",
"zeno 0.3.2 (git+https://github.com/valadaptive/zeno?branch=tight-bounds)",
"zeromq",
]
@@ -2025,7 +2026,7 @@ dependencies = [
[[package]]
name = "fontique"
version = "0.3.0"
source = "git+https://github.com/linebender/parley.git#4bb6859668815e717491ce55e6fd9390e70b0e9a"
source = "git+https://github.com/linebender/parley.git#d53a05820799a88b23a9712d9d026378a2699403"
dependencies = [
"bytemuck",
"fontconfig-cache-parser",
@@ -3210,7 +3211,7 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniquad"
version = "0.4.8"
source = "git+https://github.com/not-fl3/miniquad#613e9959c9cc7fca72c6c96eaf74ecced99165e3"
source = "git+https://github.com/narodnik/miniquad#009352f4348f2d9ea9752246d219438d7af8c75c"
dependencies = [
"libc",
"ndk-sys",
@@ -3628,13 +3629,13 @@ dependencies = [
[[package]]
name = "parley"
version = "0.3.0"
source = "git+https://github.com/linebender/parley.git#4bb6859668815e717491ce55e6fd9390e70b0e9a"
source = "git+https://github.com/linebender/parley.git#d53a05820799a88b23a9712d9d026378a2699403"
dependencies = [
"fontique",
"hashbrown 0.15.2",
"peniko",
"skrifa",
"swash",
"swash 0.2.2",
]
[[package]]
@@ -5007,7 +5008,18 @@ source = "git+https://github.com/valadaptive/swash?branch=tight-bounds#2a6429fb8
dependencies = [
"skrifa",
"yazi",
"zeno",
"zeno 0.3.2 (git+https://github.com/valadaptive/zeno?branch=tight-bounds)",
]
[[package]]
name = "swash"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fae9a562c7b46107d9c78cd78b75bbe1e991c16734c0aee8ff0ee711fb8b620a"
dependencies = [
"skrifa",
"yazi",
"zeno 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
@@ -7755,6 +7767,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeno"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc0de2315dc13d00e5df3cd6b8d2124a6eaec6a2d4b6a1c5f37b7efad17fcc17"
[[package]]
name = "zeno"
version = "0.3.2"

View File

@@ -9,7 +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" }
# Currently latest version links to freetype-sys 0.19 but we use 0.21
#harfbuzz-sys = "0.6.1"
@@ -53,6 +53,7 @@ parley = { git = "https://github.com/linebender/parley.git" }
swash = { git = "https://github.com/valadaptive/swash", branch = "tight-bounds" }
# Use same zeno specified by swash
zeno = { git = "https://github.com/valadaptive/zeno", branch = "tight-bounds" }
peniko = "*"
[features]
emulate-android = []

View File

@@ -45,20 +45,21 @@ ENV NDK_HOME /usr/local/android-ndk-r25
# Copy contents to container. Should only use this on a clean directory
WORKDIR /root/
RUN git clone https://github.com/not-fl3/cargo-quad-apk cargo-apk
#RUN git clone https://github.com/not-fl3/cargo-quad-apk cargo-apk
RUN git clone --branch quad_injs https://github.com/narodnik/cargo-quad-apk cargo-apk
# For deterministic builds, we want a deterministic toolchain
RUN cd /root/cargo-apk && git checkout 8962f6888e748e201f6ac2ced411669a3f939e07
# ArmV7a
#ENV CC ${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi31-clang
#ENV CXX ${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi31-clang++
#ENV AR ${NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar
#ENV CXXSTDLIB c++
# Arm64V8a
# X86
#RUN cd /root/cargo-apk && git checkout 8962f6888e748e201f6ac2ced411669a3f939e07
# Install binary
RUN cargo install --path /root/cargo-apk
# Add build-tools to PATH, for apksigner
ENV PATH="/opt/android-sdk-linux/build-tools/31.0.0/:${PATH}"
# Lets cache packages for faster builds
RUN git clone https://codeberg.org/darkrenaissance/darkfi
COPY Cargo.lock darkfi/bin/app/
WORKDIR /root/darkfi/bin/app/
RUN cargo fetch
RUN rm -fr /root/darkfi/

View File

@@ -74,17 +74,17 @@ dev: $(SRC) fonts
./darkfi-app
apk: $(SRC) fonts
podman run -v $(shell pwd)/../../:/root/darkfi -w /root/darkfi/bin/app/ -t apk cargo quad-apk build $(DEV_FEATURES)
podman run -v /home/narodnik/src/stuff/miniquad:/root/mq -v $(shell pwd)/../../:/root/darkfi -w /root/darkfi/bin/app/ -t apk cargo quad-apk build $(DEV_FEATURES)
-mv $(DEBUG_APK) .
-adb uninstall darkfi.app
adb install darkfi-app.apk
-adb uninstall darkfi.darkfi_app
adb install -r darkfi-app.apk
reset
adb logcat -c
adb logcat -v color -s darkfi -s SAPP -s libc -s DEBUG -s ActivityManager -s ActivityTaskManager -s WindowManager
adb logcat -v color -s darkfi -s SAPP -s libc -s DEBUG -s ActivityManager -s ActivityTaskManager -s WindowManager -s AndroidRuntime -s rkfi.darkfi_app
# Useful for dev
cli:
podman run -v $(shell pwd)/../../:/root/darkfi -w /root/darkfi/bin/app/ -it apk bash
podman run -v /home/narodnik/src/stuff/parley:/root/parley -v /home/narodnik/src/stuff/miniquad:/root/mq -v $(shell pwd)/../../:/root/darkfi -w /root/darkfi/bin/app/ -it apk bash
clean:
podman run -v $(shell pwd):/root/dw -w /root/dw -t apk rm -fr target/

View File

@@ -1,26 +1,102 @@
//% IMPORTS
import android.view.inputmethod.InputMethodManager;
import android.os.Environment;
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 java.util.HashMap;
import autosuggest.InvisibleInputView;
import autosuggest.CustomInputConnection;
//% END
//% MAIN_ACTIVITY_BODY
public void cancelComposition() {
InputMethodManager imm =
(InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE);
imm.restartInput(view);
private ViewGroup rootView;
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);
}
});
}
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);
}
InputMethodManager imm = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE);
imm.showSoftInput(iv, InputMethodManager.SHOW_IMPLICIT);
}
});
return true;
}
public CustomInputConnection getInputConnect(int id) {
InvisibleInputView iv = editors.get(id);
if (iv == null) {
return null;
}
return iv.inputConnection;
}
public boolean setText(final int id, final String txt) {
final InvisibleInputView iv = editors.get(id);
if (iv == null || iv.inputConnection == null) {
return false;
}
// Maybe do this on the UI thread?
iv.inputConnection.setEditableText(txt, txt.length(), txt.length(), 0, 0);
return true;
}
/*
// Editable string with the spans displayed inline
public String getDebugEditableStr() {
String edit = view.inputConnection.debugEditableStr();
Log.d("darkfi", "getDebugEditableStr() -> " + edit);
return edit;
}
*/
public String getAppDataPath() {
return getApplicationContext().getDataDir().getAbsolutePath();
}
public String getExternalStoragePath() {
return getApplicationContext().getExternalFilesDir(null).getAbsolutePath();
//return Environment.getExternalStorageDirectory().getAbsolutePath();
}
public int getKeyboardHeight() {
@@ -35,16 +111,14 @@ public int getKeyboardHeight() {
//% END
//% QUAD_SURFACE_ON_CREATE_INPUT_CONNECTION
//% MAIN_ACTIVITY_ON_CREATE
// Needed to fix error: unreachable statement in Java
if (true) {
outAttrs.inputType = EditorInfo.TYPE_CLASS_TEXT
| EditorInfo.TYPE_TEXT_FLAG_AUTO_CORRECT;
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN
| EditorInfo.IME_ACTION_NONE;
return new CustomInputConnection(this, outAttrs);
}
rootView = layout;
editors = new HashMap<>();
view.setFocusable(false);
view.setFocusableInTouchMode(false);
view.clearFocus();
//% END

View File

@@ -44,8 +44,8 @@ import android.view.inputmethod.InputMethodManager;
// It then adapts android's IME to chrome's RenderWidgetHostView using the
// native ImeAdapterAndroid via the outer class ImeAdapter.
public class CustomInputConnection extends BaseInputConnection {
private static final boolean DEBUG = false;
private int ID = (int)(Math.random() * (1 << 30));
private static final boolean DEBUG = true;
private int id = -1;
private View mInternalView;
//private ImeAdapter mImeAdapter;
@@ -54,12 +54,15 @@ public class CustomInputConnection extends BaseInputConnection {
private int numBatchEdits;
private boolean shouldUpdateImeSelection;
native static void onCompose(String text, int newCursorPos, boolean isCommit);
native static void onSetComposeRegion(int start, int end);
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);
//private AdapterInputConnection(View view, ImeAdapter imeAdapter, EditorInfo outAttrs) {
public CustomInputConnection(View view, EditorInfo outAttrs) {
public CustomInputConnection(int id, View view, EditorInfo outAttrs) {
super(view, true);
this.id = id;
log("CustomInputConnection()");
mInternalView = view;
//mImeAdapter = imeAdapter;
@@ -117,7 +120,7 @@ public class CustomInputConnection extends BaseInputConnection {
private void log(String fstr, Object... args) {
if (!DEBUG) return;
String text = "(" + ID + "): " + String.format(fstr, args);
String text = "(" + id + "): " + String.format(fstr, args);
Log.d("darkfi", text);
}
@@ -138,11 +141,12 @@ public class CustomInputConnection extends BaseInputConnection {
*/
public void setEditableText(String text, int selectionStart, int selectionEnd,
int compositionStart, int compositionEnd) {
log("darkfi", "setEditableText(%s, %d, %d, %d, %d)",
text, selectionStart, selectionEnd,
log("setEditableText(%s, %d, %d, %d, %d)", text,
selectionStart, selectionEnd,
compositionStart, compositionEnd);
if (mEditable == null) {
log("setEditableText creating new editable");
mEditable = Editable.Factory.getInstance().newEditable("");
}
@@ -176,12 +180,14 @@ public class CustomInputConnection extends BaseInputConnection {
}
if (!textUnchanged) {
log("replace mEditable with: %s", text);
mEditable.replace(0, mEditable.length(), text);
}
Selection.setSelection(mEditable, selectionStart, selectionEnd);
super.setComposingRegion(compositionStart, compositionEnd);
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.
@@ -193,6 +199,7 @@ public class CustomInputConnection extends BaseInputConnection {
@Override
public Editable getEditable() {
if (mEditable == null) {
log("getEditable() [create new]");
mEditable = Editable.Factory.getInstance().newEditable("");
Selection.setSelection(mEditable, 0);
}
@@ -205,7 +212,7 @@ public class CustomInputConnection extends BaseInputConnection {
log("setComposingText(%s, %d)", text, newCursorPosition);
super.setComposingText(text, newCursorPosition);
shouldUpdateImeSelection = true;
onCompose(text.toString(), newCursorPosition, false);
onCompose(id, text.toString(), newCursorPosition, false);
return true;
}
@@ -214,7 +221,7 @@ public class CustomInputConnection extends BaseInputConnection {
log("commitText(%s, %d)", text, newCursorPosition);
super.commitText(text, newCursorPosition);
shouldUpdateImeSelection = true;
onCompose(text.toString(), newCursorPosition, text.length() > 0);
onCompose(id, text.toString(), newCursorPosition, text.length() > 0);
return true;
}
@@ -258,6 +265,27 @@ public class CustomInputConnection extends BaseInputConnection {
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(...)");
@@ -282,6 +310,7 @@ public class CustomInputConnection extends BaseInputConnection {
}
shouldUpdateImeSelection = true;
//return mImeAdapter.deleteSurroundingText(leftLength, rightLength);
onDeleteSurroundingText(id, leftLength, rightLength);
return true;
}
@@ -330,7 +359,7 @@ public class CustomInputConnection extends BaseInputConnection {
return true;
}
super.finishComposingText();
onCompose("", 0, true);
onFinishCompose(id);
return true;
}
@@ -360,7 +389,7 @@ public class CustomInputConnection extends BaseInputConnection {
int a = Math.min(start, end);
int b = Math.max(start, end);
super.setComposingRegion(a, b);
onSetComposeRegion(a, b);
onSetComposeRegion(id, a, b);
return true;
}
@@ -369,19 +398,28 @@ public class CustomInputConnection extends BaseInputConnection {
}
private InputMethodManager getInputMethodManager() {
return (InputMethodManager) mInternalView.getContext()
.getSystemService(Context.INPUT_METHOD_SERVICE);
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()");
if (mEditable != null) {
getInputMethodManager().updateSelection(mInternalView,
Selection.getSelectionStart(mEditable),
Selection.getSelectionEnd(mEditable),
getComposingSpanStart(mEditable),
getComposingSpanEnd(mEditable));
if (mEditable == null) {
return;
}
getInputMethodManager().updateSelection(
mInternalView,
Selection.getSelectionStart(mEditable),
Selection.getSelectionEnd(mEditable),
getComposingSpanStart(mEditable),
getComposingSpanEnd(mEditable)
);
log("updateImeSelection() DONE");
}
@Override
@@ -398,6 +436,7 @@ public class CustomInputConnection extends BaseInputConnection {
updateImeSelection();
shouldUpdateImeSelection = false;
}
log("endBatchEdit DONE");
return false;
}
@@ -419,7 +458,15 @@ public class CustomInputConnection extends BaseInputConnection {
}
// Append the character
xmlBuilder.append(editable.charAt(i));
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) {
@@ -453,5 +500,24 @@ public class CustomInputConnection extends BaseInputConnection {
return xmlBuilder.toString();
}
public String debugEditableStr() {
return editableToXml(mEditable);
}
public String rawText() {
return mEditable.toString();
}
public int getSelectionStart() {
return Selection.getSelectionStart(mEditable);
}
public int getSelectionEnd() {
return Selection.getSelectionEnd(mEditable);
}
public int getComposeStart() {
return getComposingSpanStart(mEditable);
}
public int getComposeEnd() {
return getComposingSpanEnd(mEditable);
}
}

View File

@@ -0,0 +1,90 @@
/* 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/>.
*/
package autosuggest;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
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;
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;
}
/*
@Override
protected void onDraw(Canvas canvas) {
Log.d("darkfi", "InvisibleInputView skipping onDraw()");
}
*/
@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());
if (inputConnection != null) {
Log.d("darkfi", " -> return existing InputConnection");
return inputConnection;
}
outAttrs.inputType = EditorInfo.TYPE_CLASS_TEXT
| EditorInfo.TYPE_TEXT_FLAG_AUTO_CORRECT;
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN
| EditorInfo.IME_ACTION_NONE;
inputConnection = new CustomInputConnection(id, this, outAttrs);
onCreateInputConnect(id);
return inputConnection;
}
@Override
protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
Log.d("darkfi", "onFocusChanged: " + gainFocus);
}
}

View File

@@ -1,5 +1,7 @@
main_activity_inject = "java/MainActivity.java"
java_files = [
"java/autosuggest/CustomInputConnection.java",
"java/autosuggest/InvisibleInputView.java",
#"java/autosuggest/InvisibleInputManager.java",
]

View File

@@ -22,107 +22,277 @@ use std::{
sync::{LazyLock, Mutex as SyncMutex},
};
use crate::AndroidSuggestEvent;
macro_rules! call_mainactivity_int_method {
($method:expr, $sig:expr $(, $args:expr)*) => {{
unsafe {
let env = android::attach_jni_env();
ndk_utils::call_int_method!(env, android::ACTIVITY, $method, $sig $(, $args)*)
}
}};
}
macro_rules! call_mainactivity_str_method {
($method:expr) => {{
unsafe {
let env = android::attach_jni_env();
let text = ndk_utils::call_object_method!(
env,
android::ACTIVITY,
$method,
"()Ljava/lang/String;"
);
ndk_utils::get_utf_str!(env, text)
}
}};
}
struct GlobalData {
sender: Option<async_channel::Sender<AndroidSuggestEvent>>,
next_id: usize,
}
unsafe impl Send for GlobalData {}
unsafe impl Sync for GlobalData {}
static GLOBALS: LazyLock<SyncMutex<GlobalData>> =
LazyLock::new(|| SyncMutex::new(GlobalData { sender: None }));
LazyLock::new(|| SyncMutex::new(GlobalData { sender: None, next_id: 0 }));
pub enum AndroidSuggestEvent {
Compose { text: String, cursor_pos: i32, is_commit: bool },
ComposeRegion { start: usize, end: usize },
#[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,
) {
trace!(target: "android", "onInit() CALLED");
assert!(id >= 0);
let id = id as usize;
if let Some(sender) = &GLOBALS.lock().unwrap().sender {
trace!(target: "android", "onInit()");
let _ = sender.try_send(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;
if let Some(sender) = &GLOBALS.lock().unwrap().sender {
let _ = sender.try_send(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);
if let Some(sender) = &GLOBALS.lock().unwrap().sender {
let _ = sender.try_send(AndroidSuggestEvent::Compose {
text: text.to_string(),
cursor_pos,
is_commit: is_commit != 0,
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,
) {
let begin = std::cmp::min(start, end);
let end = std::cmp::max(start, end);
if begin < 0 || end < 0 {
warn!(target: "android", "setComposeRegion({start}, {end}) is < 0 so skipping");
return
}
let start = begin as usize;
let end = end as usize;
assert!(id >= 0);
let id = id as usize;
if let Some(sender) = &GLOBALS.lock().unwrap().sender {
let _ = sender.try_send(AndroidSuggestEvent::ComposeRegion { start, end });
let _ = sender.try_send(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;
if let Some(sender) = &GLOBALS.lock().unwrap().sender {
let _ = sender.try_send(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;
if let Some(sender) = &GLOBALS.lock().unwrap().sender {
let _ = sender.try_send(AndroidSuggestEvent::DeleteSurroundingText {
left: left as usize,
right: right as usize,
});
}
}
pub fn set_sender(sender: async_channel::Sender<AndroidSuggestEvent>) {
GLOBALS.lock().unwrap().sender = Some(sender);
}
pub fn cancel_composition() {
pub fn create_composer(sender: async_channel::Sender<AndroidSuggestEvent>) -> usize {
let composer_id = {
let mut globals = GLOBALS.lock().unwrap();
let id = globals.next_id;
globals.next_id += 1;
globals.sender = Some(sender);
id
};
unsafe {
let env = android::attach_jni_env();
ndk_utils::call_void_method!(env, android::ACTIVITY, "createComposer", "(I)V", composer_id);
}
composer_id
}
ndk_utils::call_void_method!(env, android::ACTIVITY, "cancelComposition", "()V");
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 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());
ndk_utils::call_bool_method!(
env,
android::ACTIVITY,
"setText",
"(ILjava/lang/String;)Z",
id as i32,
jtext
)
};
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})");
unsafe {
let env = android::attach_jni_env();
let input_connect = ndk_utils::call_object_method!(
env,
android::ACTIVITY,
"getInputConnect",
"(I)Lautosuggest/CustomInputConnection;",
id as i32
);
if input_connect.is_null() {
return None
}
ndk_utils::call_bool_method!(env, input_connect, "beginBatchEdit", "()Z");
ndk_utils::call_bool_method!(
env,
input_connect,
"setSelection",
"(II)Z",
select_start,
select_end
);
ndk_utils::call_bool_method!(env, input_connect, "endBatchEdit", "()Z");
}
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_connect = ndk_utils::call_object_method!(
env,
android::ACTIVITY,
"getInputConnect",
"(I)Lautosuggest/CustomInputConnection;",
id as i32
);
if input_connect.is_null() {
return None
}
let buffer =
ndk_utils::call_object_method!(env, input_connect, "rawText", "()Ljava/lang/String;");
let buffer = ndk_utils::get_utf_str!(env, buffer).to_string();
let select_start =
ndk_utils::call_int_method!(env, input_connect, "getSelectionStart", "()I");
let select_end = ndk_utils::call_int_method!(env, input_connect, "getSelectionEnd", "()I");
let compose_start =
ndk_utils::call_int_method!(env, input_connect, "getComposeStart", "()I");
let compose_end = ndk_utils::call_int_method!(env, input_connect, "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_keyboard_height() -> usize {
unsafe {
let env = android::attach_jni_env();
ndk_utils::call_int_method!(env, android::ACTIVITY, "getKeyboardHeight", "()I") as usize
}
call_mainactivity_int_method!("getKeyboardHeight", "()I") as usize
}
pub fn get_appdata_path() -> PathBuf {
let path = unsafe {
let env = android::attach_jni_env();
let text = ndk_utils::call_object_method!(
env,
android::ACTIVITY,
"getAppDataPath",
"()Ljava/lang/String;"
);
ndk_utils::get_utf_str!(env, text).to_string()
};
path.into()
call_mainactivity_str_method!("getAppDataPath").into()
}
pub fn get_external_storage_path() -> PathBuf {
let path = unsafe {
let env = android::attach_jni_env();
let text = ndk_utils::call_object_method!(
env,
android::ACTIVITY,
"getExternalStoragePath",
"()Ljava/lang/String;"
);
ndk_utils::get_utf_str!(env, text).to_string()
};
path.into()
call_mainactivity_str_method!("getExternalStoragePath").into()
}

View File

@@ -314,17 +314,14 @@ pub fn create_chatedit(name: &str) -> SceneNode {
prop.set_ui_text("Is Focused", "A focused EditBox receives input");
node.add_property(prop).unwrap();
let mut prop = Property::new("min_height", PropertyType::Float32, PropertySubType::Pixel);
prop.set_ui_text("Min Height", "Minimum height");
let mut prop = Property::new("height_range", PropertyType::Float32, PropertySubType::Pixel);
prop.set_ui_text("Min/Max Height", "Minimum and Maximum height");
prop.set_range_f32(0., f32::MAX);
prop.set_array_len(2);
node.add_property(prop).unwrap();
let mut prop = Property::new("max_height", PropertyType::Float32, PropertySubType::Pixel);
prop.set_ui_text("Max Height", "Maximum height");
prop.set_range_f32(0., f32::MAX);
node.add_property(prop).unwrap();
let prop = Property::new("height", PropertyType::Float32, PropertySubType::Pixel);
let mut prop = Property::new("content_height", PropertyType::Float32, PropertySubType::Pixel);
prop.set_ui_text("Content Height", "The actual text's inner height");
node.add_property(prop).unwrap();
let mut prop = Property::new("rect", PropertyType::Float32, PropertySubType::Pixel);
@@ -332,6 +329,7 @@ pub fn create_chatedit(name: &str) -> SceneNode {
prop.allow_exprs();
node.add_property(prop).unwrap();
// DEPRECATED ------------
let prop = Property::new("baseline", PropertyType::Float32, PropertySubType::Pixel);
node.add_property(prop).unwrap();
@@ -340,6 +338,13 @@ pub fn create_chatedit(name: &str) -> SceneNode {
let prop = Property::new("descent", PropertyType::Float32, PropertySubType::Pixel);
node.add_property(prop).unwrap();
//------------------------
let mut prop = Property::new("lineheight", PropertyType::Float32, PropertySubType::Pixel);
prop.set_ui_text("Line Height", "Line height/lead (em)");
prop.set_defaults_f32(vec![1.2]).unwrap();
prop.set_range_f32(0., f32::MAX);
node.add_property(prop).unwrap();
let mut prop = Property::new("scroll", PropertyType::Float32, PropertySubType::Pixel);
prop.set_range_f32(0., f32::MAX);

View File

@@ -479,16 +479,19 @@ pub async fn make(app: &App, window: SceneNodePtr) {
})
.await;
layer_node.clone().link(node);
*/
// Text edit
let node = create_chatedit("editz");
node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
node.set_property_bool(atom, Role::App, "is_focused", true).unwrap();
node.set_property_f32(atom, Role::App, "max_height", 400.).unwrap();
let prop = node.get_property("height_range").unwrap();
prop.clone().set_f32(atom, Role::App, 0, 100.).unwrap();
prop.clone().set_f32(atom, Role::App, 1, 400.).unwrap();
let prop = node.get_property("rect").unwrap();
prop.clone().set_f32(atom, Role::App, 0, 0.).unwrap();
prop.clone().set_f32(atom, Role::App, 0, 100.).unwrap();
prop.clone().set_f32(atom, Role::App, 1, 300.).unwrap();
prop.clone().set_expr(atom, Role::App, 2, expr::load_var("parent_w")).unwrap();
prop.clone().set_f32(atom, Role::App, 3, 50.).unwrap();
@@ -536,5 +539,4 @@ pub async fn make(app: &App, window: SceneNodePtr) {
})
.await;
layer_node.clone().link(node);
*/
}

View File

@@ -212,7 +212,7 @@ pub fn setup_logging() {
#[cfg(target_os = "android")]
{
use android::AndroidLoggerWrapper;
let android_logger = AndroidLoggerWrapper::new(LevelFilter::Debug, cfg);
let android_logger = AndroidLoggerWrapper::new(LevelFilter::Trace, cfg);
loggers.push(android_logger);
}

View File

@@ -49,6 +49,16 @@ extern crate log;
#[allow(unused_imports)]
use log::LevelFilter;
#[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;
@@ -68,6 +78,7 @@ mod scene;
mod shape;
use scene::SceneNode as SceneNode3;
mod text;
mod text2;
mod ui;
mod util;

View File

@@ -35,7 +35,6 @@ use crate::gfx::Rectangle;
mod atlas;
pub use atlas::{make_texture_atlas, Atlas, RenderedAtlas};
pub mod atlas2;
mod ft;
use ft::{render_glyph, FreetypeFace, Sprite, SpritePtr};
mod shape;

285
bin/app/src/text2/atlas.rs Normal file
View File

@@ -0,0 +1,285 @@
/* 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 crate::{
error::Result,
gfx::{GfxTextureId, ManagedTexturePtr, Rectangle, RenderApi},
mesh::Color,
};
/// Prevents render artifacts from aliasing.
/// Even with aliasing turned off, some bleed still appears possibly
/// due to UV coord calcs. Adding a gap perfectly fixes this.
const ATLAS_GAP: usize = 2;
/*
/// Convenience wrapper fn. Use if rendering a single line of glyphs.
pub fn make_texture_atlas(render_api: &RenderApi, glyphs: &Vec<Glyph>) -> RenderedAtlas {
let mut atlas = Atlas::new(render_api);
atlas.push(&glyphs);
atlas.make()
}
*/
//pub struct Sprite(swash::scale::image::Image);
/// Responsible for aggregating glyphs, and then producing a single software
/// blitted texture usable in a single draw call.
/// This makes OpenGL batch precomputation of meshes efficient.
///
/// ```rust
/// let mut atlas = Atlas::new(&render_api);
/// atlas.push(&glyphs); // repeat as needed for shaped lines
/// let atlas = atlas.make().unwrap();
/// let uv = atlas.fetch_uv(glyph_id).unwrap();
/// let atlas_texture_id = atlas.texture_id;
/// ```
pub struct Atlas<'a> {
scaler: swash::scale::Scaler<'a>,
glyph_ids: Vec<swash::GlyphId>,
sprites: Vec<swash::scale::image::Image>,
// LHS x pos of glyph
x_pos: Vec<usize>,
width: usize,
height: usize,
render_api: &'a RenderApi,
}
impl<'a> Atlas<'a> {
pub fn new(scaler: swash::scale::Scaler<'a>, render_api: &'a RenderApi) -> Self {
Self {
scaler,
glyph_ids: vec![],
sprites: vec![],
x_pos: vec![],
width: ATLAS_GAP,
// Not really important to set a value here since it will
// get overwritten.
// FYI glyphs have a gap on all sides (top and bottom here).
height: 2 * ATLAS_GAP,
render_api,
}
}
pub fn push_glyph(&mut self, glyph: parley::Glyph) {
if self.glyph_ids.contains(&glyph.id) {
return
}
self.glyph_ids.push(glyph.id);
let rendered_glyph = swash::scale::Render::new(
// Select our source order
&[
swash::scale::Source::ColorOutline(0),
swash::scale::Source::ColorBitmap(swash::scale::StrikeWith::BestFit),
swash::scale::Source::Outline,
],
)
// Select the simple alpha (non-subpixel) format
.format(zeno::Format::Alpha)
.render(&mut self.scaler, glyph.id)
.unwrap();
let glyph_width = rendered_glyph.placement.width as usize;
let glyph_height = rendered_glyph.placement.height as usize;
self.sprites.push(rendered_glyph);
self.x_pos.push(self.width);
// Gap on the top and bottom
let height = ATLAS_GAP + glyph_height + ATLAS_GAP;
self.height = std::cmp::max(height, self.height);
// Gap between glyphs and on both sides
self.width += glyph_width + ATLAS_GAP;
}
fn render(&self) -> Vec<u8> {
let mut atlas = vec![255, 255, 255, 0].repeat(self.width * self.height);
// For drawing debug lines we want a single white pixel.
// This is very useful to have in our texture for debugging.
atlas[0] = 255;
atlas[1] = 255;
atlas[2] = 255;
atlas[3] = 255;
let y = ATLAS_GAP;
// Copy all the sprites to our atlas.
// They should have ATLAS_GAP spacing on all sides to avoid bleeding.
for (sprite, x) in self.sprites.iter().zip(self.x_pos.iter()) {
copy_image(sprite, *x, y, &mut atlas, self.width);
}
atlas
}
fn compute_uvs(&self) -> Vec<Rectangle> {
// UV coords are in the range [0, 1]
let mut uvs = Vec::with_capacity(self.sprites.len());
let (self_w, self_h) = (self.width as f32, self.height as f32);
let y = ATLAS_GAP as f32;
for (sprite, x) in self.sprites.iter().zip(self.x_pos.iter()) {
let x = *x as f32;
let sprite_w = sprite.placement.width as f32;
let sprite_h = sprite.placement.height as f32;
let uv = Rectangle {
x: x / self_w,
y: y / self_h,
w: sprite_w / self_w,
h: sprite_h / self_h,
};
uvs.push(uv);
}
uvs
}
/// Debug method
pub fn dump(&self, output_path: &str) {
let atlas = self.render();
let img = image::RgbaImage::from_raw(self.width as u32, self.height as u32, atlas).unwrap();
img.save(output_path);
}
/// Invalidate this atlas and produce the finalized result.
/// Each glyph is given a sub-rect within the texture, accessible by calling
/// `rendered_atlas.fetch_uv(my_glyph_id)`.
/// The texture ID is a struct member: `rendered_atlas.texture_id`.
pub fn make(self) -> RenderedAtlas {
//if self.glyph_ids.is_empty() {
// return Err(Error::AtlasIsEmpty);
//}
assert_eq!(self.glyph_ids.len(), self.sprites.len());
assert_eq!(self.glyph_ids.len(), self.x_pos.len());
let atlas = self.render();
let texture = self.render_api.new_texture(self.width as u16, self.height as u16, atlas);
let uv_rects = self.compute_uvs();
let glyph_ids = self.glyph_ids;
let mut infos = Vec::with_capacity(self.sprites.len());
for (uv_rect, sprite) in uv_rects.into_iter().zip(self.sprites.into_iter()) {
let is_color = match sprite.content {
swash::scale::image::Content::Mask => false,
swash::scale::image::Content::SubpixelMask => unimplemented!(),
swash::scale::image::Content::Color => true,
};
infos.push(GlyphInfo { uv_rect, place: sprite.placement, is_color });
}
RenderedAtlas { glyph_ids, infos, texture }
}
}
/// Copy a sprite to (x, y) position within the atlas texture.
/// Both image formats are RGBA flat vecs.
fn copy_image(
sprite: &swash::scale::image::Image,
x: usize,
y: usize,
atlas: &mut Vec<u8>,
atlas_width: usize,
) {
let sprite_width = sprite.placement.width as usize;
let sprite_height = sprite.placement.height as usize;
match sprite.content {
swash::scale::image::Content::Mask => {
let mut i = 0;
for pixel_y in 0..sprite_height {
for pixel_x in 0..sprite_width {
let src_alpha = sprite.data[i];
let dest_y = (y + pixel_y) * atlas_width;
let off_dest = 4 * (dest_y + pixel_x + x);
//atlas[off_dest] = 255;
//atlas[off_dest + 1] = 255;
//atlas[off_dest + 2] = 255;
atlas[off_dest + 3] = src_alpha;
i += 1;
}
}
}
swash::scale::image::Content::SubpixelMask => unimplemented!(),
swash::scale::image::Content::Color => {
let row_size = sprite_width * 4;
for (pixel_y, row) in sprite.data.chunks_exact(row_size).enumerate() {
for (pixel_x, pixel) in row.chunks_exact(4).enumerate() {
assert_eq!(pixel.len(), 4);
let src_y = pixel_y * sprite_width;
let off_src = 4 * (src_y + pixel_x);
let dest_y = (y + pixel_y) * atlas_width;
let off_dest = 4 * (dest_y + pixel_x + x);
atlas[off_dest] = pixel[0];
atlas[off_dest + 1] = pixel[1];
atlas[off_dest + 2] = pixel[2];
atlas[off_dest + 3] = pixel[3];
}
}
}
}
}
#[derive(Clone)]
pub struct GlyphInfo {
/// UV rectangle within the texture.
pub uv_rect: Rectangle,
/// Placement of the sprite used to calc the rect
pub place: zeno::Placement,
pub is_color: bool,
}
/// Final result computed from `Atlas::make()`.
#[derive(Clone)]
pub struct RenderedAtlas {
glyph_ids: Vec<swash::GlyphId>,
infos: Vec<GlyphInfo>,
/// Allocated atlas texture.
pub texture: ManagedTexturePtr,
}
impl RenderedAtlas {
/// Get UV coords for a glyph within the rendered atlas.
pub fn fetch_uv(&self, glyph_id: swash::GlyphId) -> Option<&GlyphInfo> {
let glyphs_len = self.glyph_ids.len();
assert_eq!(glyphs_len, self.infos.len());
for i in 0..glyphs_len {
if self.glyph_ids[i] == glyph_id {
return Some(&self.infos[i])
}
}
None
}
}

View File

@@ -0,0 +1,152 @@
/* 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 crate::{
android,
gfx::Point,
mesh::Color,
prop::{PropertyColor, PropertyFloat32},
text2::get_ctx,
};
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,
is_init: bool,
layout: parley::Layout<Color>,
font_size: PropertyFloat32,
text_color: PropertyColor,
window_scale: PropertyFloat32,
lineheight: PropertyFloat32,
}
impl Editor {
pub async fn new(
font_size: PropertyFloat32,
text_color: PropertyColor,
window_scale: PropertyFloat32,
lineheight: PropertyFloat32,
) -> Self {
Self {
composer_id: usize::MAX,
is_init: false,
layout: Default::default(),
font_size,
text_color,
window_scale,
lineheight,
}
}
pub fn init(&mut self) {
android::focus(self.composer_id).unwrap();
}
pub fn setup(&mut self) {
assert!(self.composer_id != usize::MAX);
t!("Initialized composer [{}]", self.composer_id);
let atxt = "A berry is small 😊 and pulpy.";
android::set_text(self.composer_id, atxt).unwrap();
self.is_init = true;
}
pub async fn refresh(&mut self) {
let font_size = self.font_size.get();
let text_color = self.text_color.get();
let window_scale = self.window_scale.get();
let lineheight = self.lineheight.get();
let edit = android::get_editable(self.composer_id).unwrap();
t!("refesh buffer = {}", edit.buffer);
let mut txt_ctx = get_ctx().await;
self.layout = txt_ctx.make_layout(
&edit.buffer,
text_color,
font_size,
lineheight,
window_scale,
None,
);
}
pub fn layout(&self) -> &parley::Layout<Color> {
&self.layout
}
pub fn move_to_pos(&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();
android::set_selection(self.composer_id, pos, pos);
t!(" {cursor_idx} => {pos}");
}
pub fn get_cursor_pos(&self) -> Option<Point> {
if !self.is_init {
return None
}
let lineheight = self.lineheight.get();
let edit = android::get_editable(self.composer_id).unwrap();
//let buffer = android::get_raw_text(self.composer_id).unwrap();
//let sel_start = android::get_selection_start(self.composer_id).unwrap();
//let sel_end = android::get_selection_end(self.composer_id).unwrap();
//if sel_start != sel_end || sel_start < 0 {
// return None
//}
let cursor_byte_idx = char16_to_byte_index(&edit.buffer, edit.select_start).unwrap();
let cursor = if cursor_byte_idx >= edit.buffer.len() {
parley::Cursor::from_byte_index(
&self.layout,
edit.buffer.len(),
parley::Affinity::Upstream,
)
} else {
parley::Cursor::from_byte_index(
&self.layout,
cursor_byte_idx,
parley::Affinity::Downstream,
)
};
let cursor_rect = cursor.geometry(&self.layout, lineheight);
let cursor_pos = Point::new(cursor_rect.x0 as f32, cursor_rect.y0 as f32);
Some(cursor_pos)
}
}

View File

@@ -0,0 +1,27 @@
/* 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/>.
*/
#[cfg(target_os = "android")]
mod android;
#[cfg(target_os = "android")]
pub use android::Editor;
#[cfg(not(target_os = "android"))]
mod parley;
#[cfg(not(target_os = "android"))]
pub use parley::Editor;

View File

@@ -0,0 +1,118 @@
/* 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 crate::{
gfx::Point,
mesh::Color,
prop::{PropertyColor, PropertyFloat32},
text2::{get_ctx, TextContext, FONT_STACK},
};
macro_rules! t { ($($arg:tt)*) => { trace!(target: "text::editor", $($arg)*); } }
pub struct Editor {
editor: parley::PlainEditor<Color>,
font_size: PropertyFloat32,
text_color: PropertyColor,
window_scale: PropertyFloat32,
lineheight: PropertyFloat32,
}
impl Editor {
pub async fn new(
font_size: PropertyFloat32,
text_color: PropertyColor,
window_scale: PropertyFloat32,
lineheight: PropertyFloat32,
) -> Self {
let editor = parley::PlainEditor::new(1.);
let mut self_ = Self { editor, font_size, text_color, window_scale, lineheight };
self_.refresh().await;
self_
}
pub fn init(&mut self) {}
pub fn setup(&mut self) {}
pub async fn refresh(&mut self) {
let font_size = self.font_size.get();
let text_color = self.text_color.get();
let window_scale = self.window_scale.get();
let lineheight = self.lineheight.get();
self.editor.set_scale(window_scale);
let mut styles = parley::StyleSet::new(font_size);
styles.insert(parley::StyleProperty::LineHeight(lineheight));
styles.insert(parley::StyleProperty::FontStack(parley::FontStack::List(FONT_STACK.into())));
styles.insert(parley::StyleProperty::Brush(text_color));
*self.editor.edit_styles() = styles;
let mut txt_ctx = get_ctx().await;
let (font_ctx, layout_ctx) = txt_ctx.borrow();
self.editor.refresh_layout(font_ctx, layout_ctx);
}
pub fn layout(&self) -> &parley::Layout<Color> {
self.editor.try_layout().unwrap()
}
pub fn move_to_pos(&self, pos: Point) {}
pub fn get_cursor_pos(&self) -> Option<Point> {
let lineheight = self.lineheight.get();
let cursor_rect = self.editor.cursor_geometry(lineheight).unwrap();
let cursor_pos = Point::new(cursor_rect.x0 as f32, cursor_rect.y0 as f32);
Some(cursor_pos)
}
pub async fn driver<'a>(&'a mut self) -> Option<DriverWrapper<'a>> {
let mut txt_ctx = get_ctx().await;
// I'm one billion percent sure this is safe and don't want to waste time
let (font_ctx, layout_ctx) = {
let (f, l) = txt_ctx.borrow();
let f: &'a mut parley::FontContext = unsafe { std::mem::transmute(f) };
let l: &'a mut parley::LayoutContext<Color> = unsafe { std::mem::transmute(l) };
(f, l)
};
let drv = self.editor.driver(font_ctx, layout_ctx);
// Storing the MutexGuard together with its dependent value drv ensures we cannot
// have a race condition and the lifetime rules are respected.
let drv = DriverWrapper { txt_ctx, drv };
Some(drv)
}
}
pub struct DriverWrapper<'a> {
txt_ctx: async_lock::MutexGuard<'static, TextContext>,
drv: parley::PlainEditorDriver<'a, Color>,
}
impl<'a> std::ops::Deref for DriverWrapper<'a> {
type Target = parley::PlainEditorDriver<'a, Color>;
fn deref(&self) -> &Self::Target {
&self.drv
}
}
impl<'a> std::ops::DerefMut for DriverWrapper<'a> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.drv
}
}

92
bin/app/src/text2/mod.rs Normal file
View File

@@ -0,0 +1,92 @@
/* 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_lock::Mutex as AsyncMutex;
use std::sync::{Arc, OnceLock};
use crate::mesh::Color;
pub mod atlas;
mod editor;
pub use editor::Editor;
mod render;
pub use render::{render_layout, DebugRenderOptions};
static TEXT_CTX: OnceLock<AsyncMutex<TextContext>> = OnceLock::new();
pub async fn get_ctx() -> async_lock::MutexGuard<'static, TextContext> {
TEXT_CTX.get_or_init(|| AsyncMutex::new(TextContext::new())).lock().await
}
pub struct TextContext {
font_ctx: parley::FontContext,
layout_ctx: parley::LayoutContext<Color>,
}
impl TextContext {
fn new() -> Self {
let mut font_ctx = parley::FontContext::new();
let font_data = include_bytes!("../../ibm-plex-mono-regular.otf") as &[u8];
let font_inf =
font_ctx.collection.register_fonts(peniko::Blob::new(Arc::new(font_data)), None);
let font_data = include_bytes!("../../NotoColorEmoji.ttf") as &[u8];
let font_inf =
font_ctx.collection.register_fonts(peniko::Blob::new(Arc::new(font_data)), None);
for (family_id, _) in font_inf {
let family_name = font_ctx.collection.family_name(family_id).unwrap();
trace!(target: "text", "Loaded font: {family_name}");
}
Self { font_ctx, layout_ctx: Default::default() }
}
pub fn borrow(&mut self) -> (&mut parley::FontContext, &mut parley::LayoutContext<Color>) {
(&mut self.font_ctx, &mut self.layout_ctx)
}
pub fn make_layout(
&mut self,
text: &str,
text_color: Color,
font_size: f32,
lineheight: f32,
window_scale: f32,
width: Option<f32>,
) -> parley::Layout<Color> {
let mut builder = self.layout_ctx.ranged_builder(&mut self.font_ctx, &text, window_scale);
builder.push_default(parley::StyleProperty::LineHeight(lineheight));
builder.push_default(parley::StyleProperty::FontSize(font_size));
builder.push_default(parley::StyleProperty::FontStack(parley::FontStack::List(
FONT_STACK.into(),
)));
builder.push_default(parley::StyleProperty::Brush(text_color));
let mut layout: parley::Layout<Color> = builder.build(&text);
layout.break_all_lines(width);
layout.align(width, parley::Alignment::Start, parley::AlignmentOptions::default());
layout
}
}
pub const FONT_STACK: &[parley::FontFamily<'_>] = &[
parley::FontFamily::Named(std::borrow::Cow::Borrowed("IBM Plex Mono")),
parley::FontFamily::Named(std::borrow::Cow::Borrowed("Noto Color Emoji")),
];

143
bin/app/src/text2/render.rs Normal file
View File

@@ -0,0 +1,143 @@
/* 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 crate::{
gfx::{GfxDrawInstruction, GfxDrawMesh, Rectangle, RenderApi},
mesh::{Color, MeshBuilder, COLOR_WHITE},
};
use super::atlas::Atlas;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct DebugRenderOptions(u32);
impl DebugRenderOptions {
pub const Off: DebugRenderOptions = DebugRenderOptions(0b00);
pub const Glyph: DebugRenderOptions = DebugRenderOptions(0b01);
pub const Baseline: DebugRenderOptions = DebugRenderOptions(0b10);
pub fn has(self, other: Self) -> bool {
(self.0 & other.0) == other.0
}
}
impl std::ops::BitOr for DebugRenderOptions {
type Output = Self;
fn bitor(self, rhs: Self) -> Self {
Self(self.0 | rhs.0)
}
}
pub fn render_layout(
layout: &parley::Layout<Color>,
render_api: &RenderApi,
) -> Vec<GfxDrawInstruction> {
render_layout_with_opts(layout, DebugRenderOptions::Off, render_api)
}
pub fn render_layout_with_opts(
layout: &parley::Layout<Color>,
opts: DebugRenderOptions,
render_api: &RenderApi,
) -> Vec<GfxDrawInstruction> {
let mut scale_cx = swash::scale::ScaleContext::new();
let mut run_idx = 0;
let mut instrs = vec![];
for line in layout.lines() {
for item in line.items() {
match item {
parley::PositionedLayoutItem::GlyphRun(glyph_run) => {
let mesh =
render_glyph_run(&mut scale_cx, &glyph_run, run_idx, opts, render_api);
instrs.push(GfxDrawInstruction::Draw(mesh));
run_idx += 1;
}
parley::PositionedLayoutItem::InlineBox(_) => {}
}
}
}
instrs
}
fn render_glyph_run(
scale_ctx: &mut swash::scale::ScaleContext,
glyph_run: &parley::GlyphRun<'_, Color>,
run_idx: usize,
opts: DebugRenderOptions,
render_api: &RenderApi,
) -> GfxDrawMesh {
let mut run_x = glyph_run.offset();
let run_y = glyph_run.baseline();
let style = glyph_run.style();
let color = style.brush;
let run = glyph_run.run();
trace!(target: "text::render", "render_glyph_run run_idx={run_idx}");
let font = run.font();
let font_size = run.font_size();
let normalized_coords = run.normalized_coords();
let font_ref = swash::FontRef::from_index(font.data.as_ref(), font.index as usize).unwrap();
let mut scaler = scale_ctx
.builder(font_ref)
.size(font_size)
.hint(true)
.normalized_coords(normalized_coords)
.build();
let mut atlas = Atlas::new(scaler, render_api);
for glyph in glyph_run.glyphs() {
atlas.push_glyph(glyph);
}
//atlas.dump(&format!("/tmp/atlas_{run_idx}.png"));
let atlas = atlas.make();
let mut mesh = MeshBuilder::new();
for glyph in glyph_run.glyphs() {
let glyph_inf = atlas.fetch_uv(glyph.id).expect("missing glyph UV rect");
let glyph_x = run_x + glyph.x;
let glyph_y = run_y - glyph.y;
run_x += glyph.advance;
let glyph_rect = Rectangle::new(
glyph_x + glyph_inf.place.left as f32,
glyph_y - glyph_inf.place.top as f32,
glyph_inf.place.width as f32,
glyph_inf.place.height as f32,
);
if opts.has(DebugRenderOptions::Glyph) {
mesh.draw_outline(&glyph_rect, [0., 1., 0., 0.7], 1.);
}
let color = if glyph_inf.is_color { COLOR_WHITE } else { color };
mesh.draw_box(&glyph_rect, color, &glyph_inf.uv_rect);
}
if opts.has(DebugRenderOptions::Baseline) {
mesh.draw_filled_box(
&Rectangle::new(glyph_run.offset(), glyph_run.baseline(), glyph_run.advance(), 1.),
[0., 0., 1., 0.7],
);
}
mesh.alloc(render_api).draw_with_texture(atlas.texture)
}

File diff suppressed because it is too large Load Diff

View File

@@ -231,8 +231,8 @@ impl Editable {
/// Reset any composition in progress
pub fn end_compose(&mut self) {
#[cfg(target_os = "android")]
crate::android::cancel_composition();
//#[cfg(target_os = "android")]
//crate::android::cancel_composition();
//debug!(target: "ui::editbox", "end_compose() [editable={self:?}]");
let final_text = self.composer.clear();

View File

@@ -1558,7 +1558,13 @@ impl UIObject for EditBox {
}
}
async fn handle_compose_text(&self, suggest_text: &str, is_commit: bool) -> bool {
/*
async fn handle_compose_text(
&self,
suggest_text: &str,
cursor_pos: i32,
is_commit: bool,
) -> bool {
t!("handle_compose_text({suggest_text}, {is_commit})");
if !self.is_active.get() {
@@ -1593,6 +1599,7 @@ impl UIObject for EditBox {
true
}
*/
}
/// Filter these char events from being handled since we handle them

View File

@@ -31,7 +31,7 @@ use crate::{
},
scene::{Pimpl, SceneNodePtr, SceneNodeWeak},
util::unixtime,
ExecutorPtr,
AndroidSuggestEvent, ExecutorPtr,
};
use super::{
@@ -305,28 +305,4 @@ impl UIObject for Layer {
}
false
}
async fn handle_compose_text(&self, suggest_text: &str, is_commit: bool) -> bool {
if !self.is_visible.get() {
return false
}
for child in self.get_children() {
let obj = get_ui_object3(&child);
if obj.handle_compose_text(suggest_text, is_commit).await {
return true
}
}
false
}
async fn handle_set_compose_region(&self, start: usize, end: usize) -> bool {
if !self.is_visible.get() {
return false
}
for child in self.get_children() {
let obj = get_ui_object3(&child);
if obj.handle_set_compose_region(start, end).await {
return true
}
}
false
}
}

View File

@@ -31,7 +31,7 @@ use crate::{
gfx::{GfxBufferId, GfxDrawCall, GfxDrawMesh, GfxTextureId, Point, Rectangle},
prop::{ModifyAction, PropertyAtomicGuard, PropertyPtr, Role},
scene::{Pimpl, SceneNode as SceneNode3, SceneNodeId, SceneNodePtr, SceneNodeWeak},
ExecutorPtr,
AndroidSuggestEvent, ExecutorPtr,
};
mod button;
@@ -104,14 +104,6 @@ pub trait UIObject: Sync {
async fn handle_touch(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool {
false
}
// Android Autosuggest
async fn handle_compose_text(&self, text: &str, is_commit: bool) -> bool {
false
}
async fn handle_set_compose_region(&self, start: usize, end: usize) -> bool {
false
}
}
pub struct DrawUpdate {

View File

@@ -32,6 +32,7 @@ use crate::{
},
scene::{Pimpl, SceneNodePtr, SceneNodeWeak},
text::{self, GlyphPositionIter, TextShaper, TextShaperPtr},
text2,
util::unixtime,
ExecutorPtr,
};
@@ -116,143 +117,18 @@ impl Text {
Pimpl::Text(self_)
}
fn regen_mesh2(&self) -> Vec<GfxDrawInstruction> {
async fn regen_mesh(&self) -> Vec<GfxDrawInstruction> {
let text = self.text.get();
let font_size = self.font_size.get();
let text_color = self.text_color.get();
let window_scale = self.window_scale.get();
let mut layout_cx = parley::LayoutContext::new();
let mut font_cx = parley::FontContext::new();
let layout = {
let mut txt_ctx = text2::get_ctx().await;
txt_ctx.make_layout(&text, text_color, font_size, 0., window_scale, None)
};
let mut builder = layout_cx.ranged_builder(&mut font_cx, &text, window_scale);
let brush_style = parley::StyleProperty::Brush(text_color);
builder.push_default(brush_style);
let font_stack = parley::FontStack::from("system-ui");
builder.push_default(font_stack);
builder.push_default(parley::StyleProperty::LineHeight(2.));
builder.push_default(parley::StyleProperty::FontSize(font_size));
let mut layout: parley::Layout<Color> = builder.build(&text);
// Perform layout (including bidi resolution and shaping) with start alignment
layout.break_all_lines(None);
layout.align(None, parley::Alignment::Start, parley::AlignmentOptions::default());
let mut scale_cx = swash::scale::ScaleContext::new();
let mut run_idx = 0;
let mut instrs = vec![];
for line in layout.lines() {
for item in line.items() {
match item {
parley::PositionedLayoutItem::GlyphRun(glyph_run) => {
let mesh = self.render_glyph_run(&mut scale_cx, &glyph_run, &text, run_idx);
instrs.push(GfxDrawInstruction::Draw(mesh));
run_idx += 1;
}
parley::PositionedLayoutItem::InlineBox(_) => {}
}
}
}
instrs
}
fn render_glyph_run(
&self,
scale_ctx: &mut swash::scale::ScaleContext,
glyph_run: &parley::GlyphRun<'_, Color>,
text: &str,
run_idx: usize,
) -> GfxDrawMesh {
let mut run_x = glyph_run.offset();
let run_y = glyph_run.baseline();
let style = glyph_run.style();
let color = style.brush;
let run = glyph_run.run();
let text = &text[run.text_range()];
t!("render_glyph_run '{text}' run_idx={run_idx}");
let font = run.font();
let font_size = run.font_size();
let normalized_coords = run.normalized_coords();
let font_ref = swash::FontRef::from_index(font.data.as_ref(), font.index as usize).unwrap();
let mut scaler = scale_ctx
.builder(font_ref)
.size(font_size)
.hint(true)
.normalized_coords(normalized_coords)
.build();
let mut atlas = text::atlas2::Atlas::new(scaler, &self.render_api);
for glyph in glyph_run.glyphs() {
atlas.push_glyph(glyph);
}
atlas.dump(&format!("/tmp/atlas_{run_idx}.png"));
let atlas = atlas.make();
let mut mesh = MeshBuilder::new();
for glyph in glyph_run.glyphs() {
let glyph_inf = atlas.fetch_uv(glyph.id).expect("missing glyph UV rect");
let glyph_x = run_x + glyph.x;
let glyph_y = run_y - glyph.y;
run_x += glyph.advance;
let glyph_rect = Rectangle::new(
glyph_x + glyph_inf.place.left as f32,
glyph_y - glyph_inf.place.top as f32,
glyph_inf.place.width as f32,
glyph_inf.place.height as f32,
);
let color = if glyph_inf.is_color { COLOR_WHITE } else { color };
mesh.draw_box(&glyph_rect, color, &glyph_inf.uv_rect);
}
mesh.alloc(&self.render_api).draw_with_texture(atlas.texture)
}
fn regen_mesh(&self) -> TextRenderInfo {
let text = self.text.get();
let font_size = self.font_size.get();
let text_color = self.text_color.get();
let baseline = self.baseline.get();
let debug = self.debug.get();
let window_scale = self.window_scale.get();
t!("Rendering label '{}'", text);
let glyphs = self.text_shaper.shape(text, font_size, window_scale);
let atlas = text::make_texture_atlas(&self.render_api, &glyphs);
let mut mesh = MeshBuilder::new();
let glyph_pos_iter = GlyphPositionIter::new(font_size, window_scale, &glyphs, baseline);
for (mut glyph_rect, glyph) in glyph_pos_iter.zip(glyphs.iter()) {
let uv_rect = atlas.fetch_uv(glyph.glyph_id).expect("missing glyph UV rect");
if debug {
mesh.draw_outline(&glyph_rect, COLOR_BLUE, 2.);
}
let mut color = text_color.clone();
if glyph.sprite.has_color {
color = COLOR_WHITE;
}
mesh.draw_box(&glyph_rect, color, uv_rect);
}
if debug {
let mut rect = self.rect.get();
rect.x = 0.;
rect.y = 0.;
mesh.draw_outline(&rect, COLOR_RED, 1.);
}
let mesh = mesh.alloc(&self.render_api);
TextRenderInfo { mesh, texture: atlas.texture }
text2::render_layout(&layout, &self.render_api)
}
async fn redraw(self: Arc<Self>) {
@@ -274,17 +150,7 @@ impl Text {
let rect = self.rect.get();
let mut instrs = vec![GfxDrawInstruction::Move(rect.pos())];
instrs.append(&mut self.regen_mesh2());
/*
let render_info = self.regen_mesh();
let mesh = GfxDrawMesh {
vertex_buffer: render_info.mesh.vertex_buffer,
index_buffer: render_info.mesh.index_buffer,
texture: Some(render_info.texture),
num_elements: render_info.mesh.num_elements,
};
*/
instrs.append(&mut self.regen_mesh().await);
Some(DrawUpdate {
key: self.dc_key,

View File

@@ -27,7 +27,7 @@ use crate::{
pubsub::Subscription,
scene::{Pimpl, SceneNodePtr, SceneNodeWeak},
util::unixtime,
ExecutorPtr,
AndroidSuggestEvent, ExecutorPtr,
};
use super::{get_children_ordered, get_ui_object3, get_ui_object_ptr, OnModify};
@@ -161,32 +161,7 @@ impl Window {
mouse_wheel_task,
touch_task,
];
tasks.append(&mut on_modify.tasks);
#[cfg(target_os = "android")]
{
let (sender, recvr) = async_channel::unbounded();
crate::android::set_sender(sender);
let me2 = me.clone();
let autosuggest_task = ex.spawn(async move {
loop {
let Ok(ev) = recvr.recv().await else {
t!("Event relayer closed");
break
};
let Some(self_) = me2.upgrade() else {
// Should not happen
panic!("self destroyed before modify_task was stopped!");
};
self_.handle_autosuggest(ev).await;
}
});
tasks.push(autosuggest_task);
}
self.tasks.set(tasks);
for child in self.get_children() {
@@ -433,23 +408,6 @@ impl Window {
}
}
#[cfg(target_os = "android")]
async fn handle_autosuggest(&self, ev: crate::android::AndroidSuggestEvent) {
use crate::android::AndroidSuggestEvent::*;
for child in self.get_children() {
let obj = get_ui_object3(&child);
let is_handled = match &ev {
Compose { text, cursor_pos, is_commit } => {
obj.handle_compose_text(&text, *is_commit).await
}
ComposeRegion { start, end } => obj.handle_set_compose_region(*start, *end).await,
};
if is_handled {
return
}
}
}
pub async fn draw(&self) {
let atom = &mut PropertyAtomicGuard::new();
let trace_id = rand::random();

View File

@@ -7,6 +7,7 @@ K = GF(q)
F.<z> = K[]
f = 4 + 3*z + 1*z^2 + 5*z^3
assert len(list(f)) == k
# Let βᵢ = K(i - 1)