From d27f5dfe4efc066813ccdc7302468b2e5d60d7e5 Mon Sep 17 00:00:00 2001 From: darkfi Date: Tue, 15 Apr 2025 13:07:31 +0200 Subject: [PATCH] app: completely rework text editing subsystem. initial draft version --- bin/app/Cargo.lock | 32 +- bin/app/Cargo.toml | 3 +- bin/app/Dockerfile | 21 +- bin/app/Makefile | 10 +- bin/app/java/MainActivity.java | 106 +- .../autosuggest/CustomInputConnection.java | 108 +- .../java/autosuggest/InvisibleInputView.java | 90 ++ bin/app/quad.toml | 2 + bin/app/src/android.rs | 276 +++- bin/app/src/app/node.rs | 21 +- bin/app/src/app/schema/test.rs | 8 +- bin/app/src/logger.rs | 2 +- bin/app/src/main.rs | 11 + bin/app/src/text/mod.rs | 1 - bin/app/src/text2/atlas.rs | 285 ++++ bin/app/src/text2/editor/android.rs | 152 ++ bin/app/src/text2/editor/mod.rs | 27 + bin/app/src/text2/editor/parley.rs | 118 ++ bin/app/src/text2/mod.rs | 92 ++ bin/app/src/text2/render.rs | 143 ++ bin/app/src/ui/chatedit.rs | 1286 +++++------------ bin/app/src/ui/editbox/editable.rs | 4 +- bin/app/src/ui/editbox/mod.rs | 9 +- bin/app/src/ui/layer.rs | 26 +- bin/app/src/ui/mod.rs | 10 +- bin/app/src/ui/text.rs | 150 +- bin/app/src/ui/win.rs | 44 +- script/research/codes/reed-solomon.sage | 1 + 28 files changed, 1788 insertions(+), 1250 deletions(-) create mode 100644 bin/app/java/autosuggest/InvisibleInputView.java create mode 100644 bin/app/src/text2/atlas.rs create mode 100644 bin/app/src/text2/editor/android.rs create mode 100644 bin/app/src/text2/editor/mod.rs create mode 100644 bin/app/src/text2/editor/parley.rs create mode 100644 bin/app/src/text2/mod.rs create mode 100644 bin/app/src/text2/render.rs diff --git a/bin/app/Cargo.lock b/bin/app/Cargo.lock index 9700c6b73..4b6646ad7 100644 --- a/bin/app/Cargo.lock +++ b/bin/app/Cargo.lock @@ -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" diff --git a/bin/app/Cargo.toml b/bin/app/Cargo.toml index 55789bc40..377a1d4ab 100644 --- a/bin/app/Cargo.toml +++ b/bin/app/Cargo.toml @@ -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 = [] diff --git a/bin/app/Dockerfile b/bin/app/Dockerfile index 2f07e77d6..9d2fb53a4 100644 --- a/bin/app/Dockerfile +++ b/bin/app/Dockerfile @@ -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/ + diff --git a/bin/app/Makefile b/bin/app/Makefile index a2628a062..579b35cf4 100644 --- a/bin/app/Makefile +++ b/bin/app/Makefile @@ -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/ diff --git a/bin/app/java/MainActivity.java b/bin/app/java/MainActivity.java index a4f95e685..648fd962e 100644 --- a/bin/app/java/MainActivity.java +++ b/bin/app/java/MainActivity.java @@ -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 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 diff --git a/bin/app/java/autosuggest/CustomInputConnection.java b/bin/app/java/autosuggest/CustomInputConnection.java index f9e05f455..7dd406796 100644 --- a/bin/app/java/autosuggest/CustomInputConnection.java +++ b/bin/app/java/autosuggest/CustomInputConnection.java @@ -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); + } } diff --git a/bin/app/java/autosuggest/InvisibleInputView.java b/bin/app/java/autosuggest/InvisibleInputView.java new file mode 100644 index 000000000..3627becb8 --- /dev/null +++ b/bin/app/java/autosuggest/InvisibleInputView.java @@ -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 . + */ + +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); + } +} + diff --git a/bin/app/quad.toml b/bin/app/quad.toml index cb74b3d03..d610ff5ac 100644 --- a/bin/app/quad.toml +++ b/bin/app/quad.toml @@ -1,5 +1,7 @@ main_activity_inject = "java/MainActivity.java" java_files = [ "java/autosuggest/CustomInputConnection.java", + "java/autosuggest/InvisibleInputView.java", + #"java/autosuggest/InvisibleInputManager.java", ] diff --git a/bin/app/src/android.rs b/bin/app/src/android.rs index 87f3ca930..8ecd85f71 100644 --- a/bin/app/src/android.rs +++ b/bin/app/src/android.rs @@ -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>, + next_id: usize, } unsafe impl Send for GlobalData {} unsafe impl Sync for GlobalData {} static GLOBALS: LazyLock> = - 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) { - GLOBALS.lock().unwrap().sender = Some(sender); -} - -pub fn cancel_composition() { +pub fn create_composer(sender: async_channel::Sender) -> 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, + pub compose_end: Option, +} + +pub fn get_editable(id: usize) -> Option { + //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() } diff --git a/bin/app/src/app/node.rs b/bin/app/src/app/node.rs index e18c6b061..c090e30ec 100644 --- a/bin/app/src/app/node.rs +++ b/bin/app/src/app/node.rs @@ -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); diff --git a/bin/app/src/app/schema/test.rs b/bin/app/src/app/schema/test.rs index c4551f3fe..e1316a5e6 100644 --- a/bin/app/src/app/schema/test.rs +++ b/bin/app/src/app/schema/test.rs @@ -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); - */ } diff --git a/bin/app/src/logger.rs b/bin/app/src/logger.rs index a0bf8a546..f51e43e4f 100644 --- a/bin/app/src/logger.rs +++ b/bin/app/src/logger.rs @@ -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); } diff --git a/bin/app/src/main.rs b/bin/app/src/main.rs index 216c8ab56..e33390f4d 100644 --- a/bin/app/src/main.rs +++ b/bin/app/src/main.rs @@ -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; diff --git a/bin/app/src/text/mod.rs b/bin/app/src/text/mod.rs index ac4eb39f9..e7ca636ac 100644 --- a/bin/app/src/text/mod.rs +++ b/bin/app/src/text/mod.rs @@ -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; diff --git a/bin/app/src/text2/atlas.rs b/bin/app/src/text2/atlas.rs new file mode 100644 index 000000000..244414397 --- /dev/null +++ b/bin/app/src/text2/atlas.rs @@ -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 . + */ + +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) -> 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, + sprites: Vec, + // LHS x pos of glyph + x_pos: Vec, + + 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 { + 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 { + // 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, + 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, + infos: Vec, + /// 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 + } +} diff --git a/bin/app/src/text2/editor/android.rs b/bin/app/src/text2/editor/android.rs new file mode 100644 index 000000000..0b5c759a2 --- /dev/null +++ b/bin/app/src/text2/editor/android.rs @@ -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 . + */ + +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 { + 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 { + 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, + + 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 { + &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 { + 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) + } +} diff --git a/bin/app/src/text2/editor/mod.rs b/bin/app/src/text2/editor/mod.rs new file mode 100644 index 000000000..46406d222 --- /dev/null +++ b/bin/app/src/text2/editor/mod.rs @@ -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 . + */ + +#[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; diff --git a/bin/app/src/text2/editor/parley.rs b/bin/app/src/text2/editor/parley.rs new file mode 100644 index 000000000..989543a43 --- /dev/null +++ b/bin/app/src/text2/editor/parley.rs @@ -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 . + */ + +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, + + 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 { + self.editor.try_layout().unwrap() + } + + pub fn move_to_pos(&self, pos: Point) {} + + pub fn get_cursor_pos(&self) -> Option { + 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> { + 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 = 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 + } +} diff --git a/bin/app/src/text2/mod.rs b/bin/app/src/text2/mod.rs new file mode 100644 index 000000000..544ca2d49 --- /dev/null +++ b/bin/app/src/text2/mod.rs @@ -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 . + */ + +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> = 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, +} + +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) { + (&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, + ) -> parley::Layout { + 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 = 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")), +]; diff --git a/bin/app/src/text2/render.rs b/bin/app/src/text2/render.rs new file mode 100644 index 000000000..f12d6892e --- /dev/null +++ b/bin/app/src/text2/render.rs @@ -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 . + */ + +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, + render_api: &RenderApi, +) -> Vec { + render_layout_with_opts(layout, DebugRenderOptions::Off, render_api) +} + +pub fn render_layout_with_opts( + layout: &parley::Layout, + opts: DebugRenderOptions, + render_api: &RenderApi, +) -> Vec { + 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) +} diff --git a/bin/app/src/ui/chatedit.rs b/bin/app/src/ui/chatedit.rs index dc10b6225..5345de5a6 100644 --- a/bin/app/src/ui/chatedit.rs +++ b/bin/app/src/ui/chatedit.rs @@ -48,8 +48,9 @@ use crate::{ pubsub::Subscription, scene::{MethodCallSub, Pimpl, SceneNodePtr, SceneNodeWeak}, text::{self, Glyph, GlyphPositionIter, TextShaperPtr}, + text2::{self, Editor}, util::{enumerate_ref, is_whitespace, min_f32, unixtime, zip4}, - ExecutorPtr, + AndroidSuggestEvent, ExecutorPtr, }; use super::{ @@ -69,304 +70,17 @@ const VERT_SCROLL_UPDATE_INC: f32 = 1.; macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui::chatedit", $($arg)*); } } macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::chatedit", $($arg)*); } } -fn is_all_whitespace(glyphs: &[Glyph]) -> bool { - for glyph in glyphs { - if !is_whitespace(&glyph.substr) { - return false - } - } - true +// 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 { + let utf16_data: Vec<_> = s.encode_utf16().take(char_idx).collect(); + let prestr = String::from_utf16(&utf16_data).ok()?; + Some(prestr.len()) } - -struct TextWrap { - editable: Editable, - select: Vec, - rendered: Option, - - font_size: PropertyFloat32, - window_scale: PropertyFloat32, - baseline: PropertyFloat32, - linespacing: PropertyFloat32, -} - -impl TextWrap { - fn new( - text_shaper: TextShaperPtr, - font_size: PropertyFloat32, - window_scale: PropertyFloat32, - baseline: PropertyFloat32, - linespacing: PropertyFloat32, - ) -> Self { - Self { - editable: Editable::new( - text_shaper, - font_size.clone(), - window_scale.clone(), - baseline.clone(), - ), - select: vec![], - rendered: None, - font_size, - window_scale, - baseline, - linespacing, - } - } - - fn clear_cache(&mut self) { - self.rendered = None; - } - fn get_render(&mut self) -> &RenderedEditable { - if self.rendered.is_none() { - //debug!(target: "ui::chatedit::text_wrap", "Regenerating render cache"); - self.rendered = Some(self.editable.render()); - } - self.rendered.as_ref().unwrap() - } - - fn wrap(&mut self, width: f32) -> WrappedLines { - let font_size = self.font_size.get(); - let window_scale = self.window_scale.get(); - let baseline = self.baseline.get(); - let linespacing = self.linespacing.get(); - - let rendered = self.get_render(); - let wrapped_glyphs = text::wrap(width, font_size, window_scale, &rendered.glyphs); - - let mut curr_pos = 0; - let mut lines: Vec<_> = wrapped_glyphs - .into_iter() - .map(|glyphs| { - let off_pos = curr_pos; - curr_pos += glyphs.len(); - WrappedLine::new(glyphs, off_pos, font_size, window_scale, baseline) - }) - .collect(); - - if let Some(last) = lines.last_mut() { - assert_eq!(last.last_pos(), rendered.glyphs.len()); - last.is_last = true; - } - - WrappedLines::new(lines, font_size, linespacing) - } - - fn cursor_pos(&mut self) -> TextPos { - let rendered = self.get_render().clone(); - self.editable.get_cursor_pos(&rendered) - } - - fn set_cursor_with_point(&mut self, point: Point, width: f32) -> TextPos { - let wrapped_lines = self.wrap(width); - let cursor_pos = wrapped_lines.point_to_pos(point); - - let rendered = self.get_render(); - let glyphs_len = rendered.glyphs.len(); - let cidx = rendered.pos_to_idx(cursor_pos); - self.editable.set_cursor_idx(cidx); - - //debug!(target: "ui::chatedit::text_wrap", "set_cursor_with_point() -> {cursor_pos} / {glyphs_len}"); - cursor_pos - } - - fn get_word_boundary(&mut self, pos: TextPos) -> (TextPos, TextPos) { - let rendered = self.get_render(); - let final_pos = rendered.glyphs.len(); - - // Find word start - let mut pos_start = pos; - while pos_start > 0 { - // Is the glyph before this pos just whitespace? - let glyph_str = &rendered.glyphs[pos_start - 1].substr; - if is_whitespace(glyph_str) { - break - } - pos_start -= 1; - } - - // Find word end - let mut pos_end = std::cmp::min(pos + 1, final_pos); - while pos_end < final_pos { - let glyph_str = &rendered.glyphs[pos_end].substr; - if is_whitespace(glyph_str) { - break - } - pos_end += 1; - } - - (pos_start, pos_end) - } - - fn delete_selected(&mut self) { - let selection = std::mem::take(&mut self.select); - - let sel = selection.first().unwrap(); - let cursor_pos = std::cmp::min(sel.start, sel.end); - - let render = self.get_render(); - let mut before_text = String::new(); - let mut after_text = String::new(); - 'next: for (pos, glyph) in render.glyphs.iter().enumerate() { - for select in &selection { - let start = std::cmp::min(select.start, select.end); - let end = std::cmp::max(select.start, select.end); - - if start <= pos && pos < end { - continue 'next - } - } - if pos <= cursor_pos { - before_text += &glyph.substr; - } else { - after_text += &glyph.substr; - } - } - self.editable.end_compose(); - self.editable.set_text(before_text, after_text); - self.clear_cache(); - } -} - -struct WrappedLine { - glyphs: Vec, - off_pos: TextPos, - - font_size: f32, - window_scale: f32, - baseline: f32, - - /// Last line in this paragraph? - /// In which case the cursor does not wrap - is_last: bool, -} - -impl WrappedLine { - fn new( - glyphs: Vec, - off_pos: TextPos, - font_size: f32, - window_scale: f32, - baseline: f32, - ) -> Self { - Self { glyphs, off_pos, font_size, window_scale, baseline, is_last: false } - } - - fn len(&self) -> usize { - self.glyphs.len() - } - - fn first_pos(&self) -> TextPos { - self.off_pos - } - fn last_pos(&self) -> TextPos { - self.off_pos + self.len() - } - - fn pos_iter(&self) -> GlyphPositionIter { - GlyphPositionIter::new(self.font_size, self.window_scale, &self.glyphs, self.baseline) - } - - fn rhs(&self) -> f32 { - match self.pos_iter().last() { - None => 0., - Some(rect) => rect.rhs(), - } - } - - fn find_closest(&self, x: f32) -> TextPos { - for (pos, glyph_rect) in self.pos_iter().enumerate() { - let next_x = glyph_rect.center().x; - if x < next_x { - return pos - } - } - - if self.is_last { - self.glyphs.len() - } else { - self.glyphs.len() - 1 - } - } -} - -struct WrappedLines { - lines: Vec, - font_size: f32, - linespacing: f32, -} - -impl WrappedLines { - fn new(lines: Vec, font_size: f32, linespacing: f32) -> Self { - Self { lines, font_size, linespacing } - } - - /// Convert an (x, y) point to a glyph pos - fn point_to_pos(&self, mut point: Point) -> TextPos { - if self.lines.is_empty() { - return 0 - } - let mut pos = 0; - for (line_idx, wrap_line) in self.lines.iter().enumerate() { - // Is it within this line? - if point.y < self.linespacing || wrap_line.is_last { - //debug!(target: "ui::chatedit::wrapped_lines", "point to pos found line: {line_idx}"); - pos += wrap_line.find_closest(point.x); - return pos - } - - // Continue to the next line - point.y -= self.linespacing; - pos += wrap_line.len(); - } - //debug!(target: "ui::chatedit::wrapped_lines", "point to pos using last line"); - panic!("point_to_pos() went past the last line") - } - - fn height(&self) -> f32 { - self.last_y() + self.linespacing - } - - fn last_y(&self) -> f32 { - if self.lines.is_empty() { - return 0. - } - (self.lines.len() - 1) as f32 * self.linespacing - } - - fn last_rhs(&self) -> f32 { - if self.lines.is_empty() { - return 0. - } - let last_line = self.lines.last().unwrap(); - let mut rhs = last_line.rhs(); - rhs += eol_nudge(self.font_size, &last_line.glyphs); - rhs - } - - fn get_glyph_info(&self, mut pos: TextPos) -> (Rectangle, usize) { - let mut y = 0.; - for (line_idx, wrap_line) in self.lines.iter().enumerate() { - assert!(!wrap_line.glyphs.is_empty()); - - if pos < wrap_line.len() { - // Cursor is on this line - let mut pos_iter = wrap_line.pos_iter(); - pos_iter.advance_by(pos).unwrap(); - - let mut glyph_rect = pos_iter.next().unwrap(); - return (glyph_rect, line_idx) - } - - pos -= wrap_line.len(); - y += self.linespacing; - } - - let rhs = self.last_rhs(); - let last_y = self.last_y(); - let final_rect = Rectangle::new(rhs, last_y, 0., self.linespacing); - let last_idx = if self.lines.is_empty() { 0 } else { self.lines.len() - 1 }; - (final_rect, last_idx) +fn byte_to_char16_index(s: &str, byte_idx: usize) -> Option { + if byte_idx > s.len() || !s.is_char_boundary(byte_idx) { + return None; } + Some(s[..byte_idx].encode_utf16().count()) } #[derive(Clone)] @@ -444,6 +158,46 @@ impl TouchInfo { } } +/* +impl std::fmt::Debug for Editor { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let mut changes = vec![]; + let sel = self.editor.raw_selection(); + if sel.is_collapsed() { + let cursor = sel.focus().index(); + changes.push((cursor, '|')); + } else { + let sel = sel.text_range(); + changes.push((sel.start, '{')); + changes.push((sel.end, '}')); + } + + if let Some(compose) = self.editor.compose() { + changes.push((compose.start, '[')); + changes.push((compose.end, ']')); + } + + changes.sort_by(|a, b| b.0.cmp(&a.0)); + + write!(f, "'")?; + let mut buffer = self.editor.raw_text(); + for (byte_idx, c) in buffer.char_indices() { + while let Some((idx, d)) = changes.last() { + if *idx > byte_idx { + break + } + + write!(f, "{}", d)?; + let _ = changes.pop(); + } + + write!(f, "{}", c)?; + } + write!(f, "'") + } +} +*/ + enum ColoringState { Start, IsCommand, @@ -471,11 +225,12 @@ pub struct ChatEdit { is_focused: PropertyBool, min_height: PropertyFloat32, max_height: PropertyFloat32, - height: PropertyFloat32, + content_height: PropertyFloat32, rect: PropertyRect, baseline: PropertyFloat32, linespacing: PropertyFloat32, descent: PropertyFloat32, + lineheight: PropertyFloat32, scroll: PropertyFloat32, scroll_speed: PropertyFloat32, padding: PropertyPtr, @@ -501,8 +256,6 @@ pub struct ChatEdit { priority: PropertyUint32, debug: PropertyBool, - text_wrap: SyncMutex, - mouse_btn_held: AtomicBool, cursor_is_visible: AtomicBool, blink_is_paused: AtomicBool, @@ -516,6 +269,8 @@ pub struct ChatEdit { window_scale: PropertyFloat32, parent_rect: SyncMutex>, is_mouse_hover: AtomicBool, + + editor: AsyncMutex, } impl ChatEdit { @@ -531,14 +286,18 @@ impl ChatEdit { let node_ref = &node.upgrade().unwrap(); let is_active = PropertyBool::wrap(node_ref, Role::Internal, "is_active", 0).unwrap(); let is_focused = PropertyBool::wrap(node_ref, Role::Internal, "is_focused", 0).unwrap(); - let min_height = PropertyFloat32::wrap(node_ref, Role::Internal, "min_height", 0).unwrap(); - let max_height = PropertyFloat32::wrap(node_ref, Role::Internal, "max_height", 0).unwrap(); - let height = PropertyFloat32::wrap(node_ref, Role::Internal, "height", 0).unwrap(); + let min_height = + PropertyFloat32::wrap(node_ref, Role::Internal, "height_range", 0).unwrap(); + let max_height = + PropertyFloat32::wrap(node_ref, Role::Internal, "height_range", 1).unwrap(); + let content_height = + PropertyFloat32::wrap(node_ref, Role::Internal, "content_height", 0).unwrap(); let rect = PropertyRect::wrap(node_ref, Role::Internal, "rect").unwrap(); let baseline = PropertyFloat32::wrap(node_ref, Role::Internal, "baseline", 0).unwrap(); let linespacing = PropertyFloat32::wrap(node_ref, Role::Internal, "linespacing", 0).unwrap(); let descent = PropertyFloat32::wrap(node_ref, Role::Internal, "descent", 0).unwrap(); + let lineheight = PropertyFloat32::wrap(node_ref, Role::Internal, "lineheight", 0).unwrap(); let scroll = PropertyFloat32::wrap(node_ref, Role::Internal, "scroll", 0).unwrap(); let scroll_speed = PropertyFloat32::wrap(node_ref, Role::Internal, "scroll_speed", 0).unwrap(); @@ -597,18 +356,19 @@ impl ChatEdit { is_focused, min_height, max_height, - height, + content_height, rect, - baseline: baseline.clone(), - linespacing: linespacing.clone(), + baseline, + linespacing, descent, + lineheight: lineheight.clone(), scroll: scroll.clone(), scroll_speed, padding, cursor_pos, font_size: font_size.clone(), text, - text_color, + text_color: text_color.clone(), text_hi_color, text_cmd_color, cursor_color, @@ -627,14 +387,6 @@ impl ChatEdit { priority, debug, - text_wrap: SyncMutex::new(TextWrap::new( - text_shaper, - font_size, - window_scale.clone(), - baseline, - linespacing, - )), - mouse_btn_held: AtomicBool::new(false), cursor_is_visible: AtomicBool::new(true), blink_is_paused: AtomicBool::new(false), @@ -644,9 +396,13 @@ impl ChatEdit { is_phone_select: AtomicBool::new(false), old_window_scale: AtomicF32::new(window_scale.get()), - window_scale, + window_scale: window_scale.clone(), parent_rect: SyncMutex::new(None), is_mouse_hover: AtomicBool::new(false), + + editor: AsyncMutex::new( + Editor::new(font_size, text_color, window_scale, lineheight).await, + ), }); //self_ @@ -658,11 +414,6 @@ impl ChatEdit { // "".to_string(), // "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.".to_string() //); - //self_ - // .text_wrap - // .lock() - // .editable - // .set_text("A berry is small and pulpy.".to_string(), "".to_string()); Pimpl::ChatEdit(self_) } @@ -685,139 +436,6 @@ impl ChatEdit { point.y += self.scroll.get(); } - /// Called whenever the text or any text property changes. - fn regen_text_mesh(&self, trace_id: u32, atom: &mut PropertyAtomicGuard) -> GfxDrawMesh { - let is_focused = self.is_focused.get(); - let text = self.text.get(); - let font_size = self.font_size.get(); - let window_scale = self.window_scale.get(); - let text_color = self.text_color.get(); - let text_hi_color = self.text_hi_color.get(); - let text_cmd_color = self.text_cmd_color.get(); - let linespacing = self.linespacing.get(); - let baseline = self.baseline.get(); - let scroll = self.scroll.get(); - let cursor_color = self.cursor_color.get(); - let debug = self.debug.get(); - - let parent_rect = self.parent_rect.lock().clone().unwrap(); - self.rect.eval_with( - vec![2], - vec![("parent_w".to_string(), parent_rect.w), ("parent_h".to_string(), parent_rect.h)], - ); - // Height is calculated from width based on wrapping - let width = self.wrap_width(); - - let (atlas, wrapped_lines, selections, under_start, under_end) = { - let mut text_wrap = self.text_wrap.lock(); - // Must happen after rect eval, which is inside regen_text_mesh - // Maybe we should take the eval out of here. - self.clamp_scroll(&mut text_wrap, atom); - - let rendered = text_wrap.get_render(); - let under_start = rendered.under_start; - let under_end = rendered.under_end; - let atlas = text::make_texture_atlas(&self.render_api, &rendered.glyphs); - let wrapped_lines = text_wrap.wrap(width); - let selections = text_wrap.select.clone(); - (atlas, wrapped_lines, selections, under_start, under_end) - }; - - let real_height = wrapped_lines.height() + self.descent.get(); - let height = real_height.clamp(self.min_height.get(), self.max_height.get()); - - self.rect.prop().set_f32(atom, Role::Internal, 3, height); - - // Eval the rect - let parent_rect = self.parent_rect.lock().clone().unwrap(); - self.rect.eval_with( - vec![0, 1, 3], - vec![ - ("parent_w".to_string(), parent_rect.w), - ("parent_h".to_string(), parent_rect.h), - ("rect_h".to_string(), height), - ], - ); - - let mut clip = self.rect.get(); - t!("Rendering text '{text}' rect={clip:?} width={width} [trace_id={trace_id}]"); - clip.x = 0.; - clip.y = 0.; - - if real_height < height { - clip.y = (height - real_height) / 2.; - } - - let mut mesh = MeshBuilder::with_clip(clip.clone()); - let mut curr_y = -scroll; - - for (line_idx, wrap_line) in wrapped_lines.lines.iter().enumerate() { - // Instead of bools, maybe we should have a GlyphStyle enum - let select_marks = self.mark_selected_glyphs(&wrap_line, &selections); - let hi_bg_color = self.hi_bg_color.get(); - self.draw_text_bg_box(&mut mesh, &select_marks, &wrap_line, curr_y, hi_bg_color); - - let cmd_marks = if line_idx == 0 { - self.mark_command_glyphs(&wrap_line) - } else { - vec![false; wrap_line.len()] - }; - let cmd_bg_color = self.cmd_bg_color.get(); - self.draw_text_bg_box(&mut mesh, &cmd_marks, &wrap_line, curr_y, cmd_bg_color); - - if under_start != under_end { - assert!(under_start < under_end); - self.draw_underline(&mut mesh, &wrap_line, curr_y, under_start, under_end); - } - - let pos_iter = wrap_line.pos_iter(); - - for (_, mut glyph_rect, glyph, is_selected, is_cmd) in zip4( - pos_iter, - wrap_line.glyphs.iter(), - select_marks.into_iter(), - cmd_marks.into_iter(), - ) { - let uv_rect = atlas.fetch_uv(glyph.glyph_id).expect("missing glyph UV rect"); - - glyph_rect.y += curr_y; - - //mesh.draw_outline(&glyph_rect, COLOR_BLUE, 2.); - let mut color = text_color.clone(); - if is_selected { - color = text_hi_color.clone(); - } else if is_cmd { - color = text_cmd_color.clone(); - } - - if glyph.sprite.has_color { - color = COLOR_WHITE; - } - - mesh.draw_box(&glyph_rect, color, uv_rect); - if self.debug.get() { - mesh.draw_outline(&glyph_rect, COLOR_RED, 1.); - } - } - - curr_y += linespacing; - } - - // Just an assert - if self.is_phone_select.load(Ordering::Relaxed) { - assert_eq!(selections.len(), 1); - let select = selections.last().unwrap(); - self.draw_phone_select_handle(&mut mesh, select.start, &wrapped_lines, -1.); - self.draw_phone_select_handle(&mut mesh, select.end, &wrapped_lines, 1.); - } - - if self.debug.get() { - mesh.draw_outline(&clip, COLOR_BLUE, 1.); - } - - mesh.alloc(&self.render_api).draw_with_texture(atlas.texture) - } - fn regen_cursor_mesh(&self) -> GfxDrawMesh { let cursor_width = self.cursor_width.get(); let cursor_ascent = self.cursor_ascent.get(); @@ -837,194 +455,7 @@ impl ChatEdit { mesh.alloc(&self.render_api).draw_untextured() } - fn get_cursor_pos(&self) -> Point { - assert!(self.is_focused.get()); - - let font_size = self.font_size.get(); - let window_scale = self.window_scale.get(); - let linespacing = self.linespacing.get(); - let scroll = self.scroll.get(); - - let width = self.wrap_width(); - - let (cursor_pos, wrapped_lines) = { - let mut text_wrap = self.text_wrap.lock(); - (text_wrap.cursor_pos(), text_wrap.wrap(width)) - }; - - let glyph_info = wrapped_lines.get_glyph_info(cursor_pos); - let lineidx = glyph_info.1; - let mut point = glyph_info.0.pos(); - point.x = point.x.clamp(0., width); - point.y = lineidx as f32 * linespacing - scroll; - point - } - - fn mark_selected_glyphs( - &self, - wrap_line: &WrappedLine, - selections: &Vec, - ) -> Vec { - let mut marks = vec![false; wrap_line.len()]; - for select in selections { - let mut start = std::cmp::min(select.start, select.end); - let mut end = std::cmp::max(select.start, select.end); - - let off_pos = wrap_line.off_pos; - - if end < off_pos || start > wrap_line.last_pos() { - continue - } - - end = std::cmp::min(end, wrap_line.last_pos()) - off_pos; - start = start.saturating_sub(off_pos); - - for i in start..end { - marks[i] = true; - } - } - marks - } - - fn mark_command_glyphs(&self, wrap_line: &WrappedLine) -> Vec { - let mut state = ColoringState::Start; - let mut marks = vec![false; wrap_line.len()]; - for (idx, glyph) in wrap_line.glyphs.iter().enumerate() { - match state { - ColoringState::Start => { - if glyph.substr == "/" { - state = ColoringState::IsCommand - } else { - state = ColoringState::Normal - } - } - ColoringState::IsCommand => { - if is_whitespace(&glyph.substr) { - state = ColoringState::Normal - } - } - _ => {} - } - - match state { - ColoringState::IsCommand => marks[idx] = true, - _ => {} - } - } - marks - } - - fn draw_text_bg_box( - &self, - mesh: &mut MeshBuilder, - select_marks: &Vec, - wrap_line: &WrappedLine, - y_off: f32, - color: Color, - ) { - let font_size = self.font_size.get(); - let baseline = self.baseline.get(); - let select_ascent = self.select_ascent.get(); - let select_descent = self.select_descent.get(); - - if select_marks.iter().all(|b| !b) { - return - } - - let mut start_x = None; - let mut end_x = None; - - for (glyph_rect, is_selected) in wrap_line.pos_iter().zip(select_marks.iter()) { - if *is_selected && start_x.is_none() { - start_x = Some(glyph_rect.x); - } - if !*is_selected && start_x.is_some() && end_x.is_none() { - end_x = Some(glyph_rect.x); - } - } - - let mut start_x = start_x.unwrap(); - - if select_marks[0] { - start_x = 0.; - } - - let end_x = match end_x { - Some(end_x) => end_x, - None => { - assert!(*select_marks.last().unwrap()); - wrap_line.rhs() + eol_nudge(font_size, &wrap_line.glyphs) - } - }; - - let select_ascent = self.select_ascent.get(); - let select_descent = self.select_descent.get(); - - let select_rect = Rectangle { - x: start_x, - y: y_off + baseline - select_ascent, - w: end_x - start_x, - h: select_ascent + select_descent, - }; - mesh.draw_box(&select_rect, color, &Rectangle::zero()); - } - - fn draw_underline( - &self, - mesh: &mut MeshBuilder, - wrap_line: &WrappedLine, - y_off: f32, - under_start: usize, - under_end: usize, - ) { - if under_start >= wrap_line.last_pos() { - return - } - if under_end < wrap_line.first_pos() { - return - } - assert!(under_start < under_end); - - let baseline = self.baseline.get(); - let text_color = self.text_color.get(); - - // Doing it like this but with advance should be easier and shorter. - //let start = start as isize - self.start_pos as isize; - //let end = end as isize - self.start_pos as isize; - //let line_start = std::cmp::max(0, start) as usize; - //assert!(end > 0); - //let line_end = std::cmp::min(self.marks.len() as isize, end) as usize; - - let mut start_x = 0.; - let mut end_x = 0.; - // When cursor lands at the end of the line - let mut rhs = 0.; - - for (glyph_idx, mut glyph_rect) in wrap_line.pos_iter().enumerate() { - if glyph_idx == under_start { - start_x = glyph_rect.x; - } - if glyph_idx == under_end { - end_x = glyph_rect.x; - } - - rhs = glyph_rect.rhs(); - } - - if under_start == 0 { - start_x = 0.; - } - - if under_end == wrap_line.last_pos() { - end_x = rhs; - } - - // We don't need to do manual clipping since MeshBuilder should do that - let underline_rect = - Rectangle { x: start_x, y: y_off + baseline + 6., w: end_x - start_x, h: 4. }; - mesh.draw_box(&underline_rect, text_color, &Rectangle::zero()); - } - + /* fn draw_phone_select_handle( &self, mesh: &mut MeshBuilder, @@ -1090,6 +521,7 @@ impl ChatEdit { let indices = vec![0, 1, 2, 0, 2, 3]; mesh.append(verts, indices); } + */ async fn change_focus(self: Arc) { if !self.is_active.get() { @@ -1101,33 +533,14 @@ impl ChatEdit { self.redraw().await; } - async fn insert_char(&self, key: char, atom: &mut PropertyAtomicGuard) { + async fn insert_char(&self, key: char) { t!("insert_char({key})"); let mut tmp = [0; 4]; let key_str = key.encode_utf8(&mut tmp); - self.insert_text(key_str, atom).await - } - async fn insert_text(&self, text: &str, atom: &mut PropertyAtomicGuard) { - t!("insert_text({text})"); - let text = { - let mut text_wrap = &mut self.text_wrap.lock(); - text_wrap.clear_cache(); - if !text_wrap.select.is_empty() { - text_wrap.delete_selected(); - self.update_select_text(&mut text_wrap, atom); - - self.is_phone_select.store(false, Ordering::Relaxed); - // Reshow cursor (if hidden) - self.hide_cursor.store(false, Ordering::Relaxed); - } - text_wrap.editable.compose(text, true); - text_wrap.editable.get_text() - }; - self.text.set(atom, text); - - self.pause_blinking(); - self.redraw().await; + let mut editor = self.editor.lock().await; + let mut drv = editor.driver().await.unwrap(); + drv.insert_or_replace_selection(&key_str); } async fn handle_shortcut( @@ -1139,24 +552,18 @@ impl ChatEdit { t!("handle_shortcut({:?}, {:?})", key, mods); #[cfg(not(target_os = "macos"))] - let modkey_pressed = mods.ctrl; + let action_mod = mods.ctrl; #[cfg(target_os = "macos")] - let modkey_pressed = mods.logo; + let action_mod = mods.logo; match key { 'a' => { - if modkey_pressed { + if action_mod { { - let mut text_wrap = self.text_wrap.lock(); - let rendered = text_wrap.get_render(); - let end_pos = rendered.glyphs.len(); - - let select = &mut text_wrap.select; - select.clear(); - select.push(Selection::new(0, end_pos)); - - self.update_select_text(&mut text_wrap, atom); + //let mut editor = self.editor.lock(); + //let mut drv = editor.driver(); + //drv.select_all(); } self.redraw().await; @@ -1164,16 +571,17 @@ impl ChatEdit { } } 'c' => { - if mods.ctrl { - //self.copy_highlighted().unwrap(); + if action_mod { return true } } 'v' => { - if modkey_pressed { - if let Some(text) = miniquad::window::clipboard_get() { - self.insert_text(&text, atom).await; - } + if action_mod { + //if let Some(text) = miniquad::window::clipboard_get() { + // let mut editor = self.editor.lock(); + // let mut drv = editor.driver(); + // drv.insert_or_replace_selection(&text); + //} return true } } @@ -1191,65 +599,65 @@ impl ChatEdit { t!("handle_key({:?}, {:?})", key, mods); match key { KeyCode::Left => { - if !self.adjust_cursor(&mods, |editable| editable.move_cursor(-1), atom) { - return false - } + //if !self.adjust_cursor(&mods, |editable| editable.move_cursor(-1), atom) { + // return false + //} self.pause_blinking(); //self.apply_cursor_scrolling(); self.redraw().await; return true } KeyCode::Right => { - if !self.adjust_cursor(&mods, |editable| editable.move_cursor(1), atom) { - return false - } + //if !self.adjust_cursor(&mods, |editable| editable.move_cursor(1), atom) { + // return false + //} self.pause_blinking(); //self.apply_cursor_scrolling(); self.redraw().await; return true } KeyCode::Kp0 => { - self.insert_char('0', atom).await; + self.insert_char('0').await; return true } KeyCode::Kp1 => { - self.insert_char('1', atom).await; + self.insert_char('1').await; return true } KeyCode::Kp2 => { - self.insert_char('2', atom).await; + self.insert_char('2').await; return true } KeyCode::Kp3 => { - self.insert_char('3', atom).await; + self.insert_char('3').await; return true } KeyCode::Kp4 => { - self.insert_char('4', atom).await; + self.insert_char('4').await; return true } KeyCode::Kp5 => { - self.insert_char('5', atom).await; + self.insert_char('5').await; return true } KeyCode::Kp6 => { - self.insert_char('6', atom).await; + self.insert_char('6').await; return true } KeyCode::Kp7 => { - self.insert_char('7', atom).await; + self.insert_char('7').await; return true } KeyCode::Kp8 => { - self.insert_char('8', atom).await; + self.insert_char('8').await; return true } KeyCode::Kp9 => { - self.insert_char('9', atom).await; + self.insert_char('9').await; return true } KeyCode::KpDecimal => { - self.insert_char('.', atom).await; + self.insert_char('.').await; return true } KeyCode::Enter | KeyCode::KpEnter => { @@ -1258,28 +666,36 @@ impl ChatEdit { } } KeyCode::Delete => { - self.delete(0, 1, atom); - self.clamp_scroll(&mut self.text_wrap.lock(), atom); + //self.delete(0, 1, atom); + //self.clamp_scroll(&mut self.text_wrap.lock(), atom); self.pause_blinking(); self.redraw().await; return true } KeyCode::Backspace => { - self.delete(1, 0, atom); - self.clamp_scroll(&mut self.text_wrap.lock(), atom); + //self.delete(1, 0, atom); + //self.clamp_scroll(&mut self.text_wrap.lock(), atom); + t!("KeyCode::Backspace"); + { + //let mut editor = self.editor.lock(); + //t!(" editor (before): {editor:?}"); + //let mut drv = editor.driver(); + //drv.backdelete(); + //t!(" editor (after): {editor:?}"); + } self.pause_blinking(); self.redraw().await; return true } KeyCode::Home => { - self.adjust_cursor(&mods, |editable| editable.move_start(), atom); + //self.adjust_cursor(&mods, |editable| editable.move_start(), atom); self.pause_blinking(); //self.apply_cursor_scrolling(); self.redraw().await; return true } KeyCode::End => { - self.adjust_cursor(&mods, |editable| editable.move_end(), atom); + //self.adjust_cursor(&mods, |editable| editable.move_end(), atom); self.pause_blinking(); //self.apply_cursor_scrolling(); self.redraw().await; @@ -1290,6 +706,7 @@ impl ChatEdit { false } + /* fn delete(&self, before: usize, after: usize, atom: &mut PropertyAtomicGuard) { let mut text_wrap = &mut self.text_wrap.lock(); if text_wrap.select.is_empty() { @@ -1307,45 +724,11 @@ impl ChatEdit { let text = text_wrap.editable.get_text(); self.text.set(atom, text); } - - fn adjust_cursor( - &self, - mods: &KeyMods, - move_cursor: impl Fn(&mut Editable), - atom: &mut PropertyAtomicGuard, - ) -> bool { - if mods.ctrl || mods.alt || mods.logo { - return false - } - - let mut text_wrap = &mut self.text_wrap.lock(); - let rendered = text_wrap.get_render().clone(); - let prev_cursor_pos = text_wrap.editable.get_cursor_pos(&rendered); - move_cursor(&mut text_wrap.editable); - let cursor_pos = text_wrap.editable.get_cursor_pos(&rendered); - d!("Adjust cursor pos to {cursor_pos}"); - - let select = &mut text_wrap.select; - - // Start selection if shift is held - if mods.shift { - // Create a new selection - if select.is_empty() { - select.push(Selection::new(prev_cursor_pos, cursor_pos)); - } - - // Update the selection - select.last_mut().unwrap().end = cursor_pos; - } else { - select.clear(); - } - - self.update_select_text(&mut text_wrap, atom); - true - } + */ /// This will select the entire word rather than move the cursor to that location fn start_touch_select(&self, touch_pos: Point, atom: &mut PropertyAtomicGuard) { + /* let mut text_wrap = &mut self.text_wrap.lock(); text_wrap.clear_cache(); text_wrap.editable.end_compose(); @@ -1369,8 +752,10 @@ impl ChatEdit { d!("Selected {select:?} from {touch_pos:?}"); self.update_select_text(&mut text_wrap, atom); + */ } + /* /// Call this whenever the selection changes to update the external property fn update_select_text(&self, text_wrap: &mut TextWrap, atom: &mut PropertyAtomicGuard) { let select = &text_wrap.select; @@ -1393,53 +778,6 @@ impl ChatEdit { let cursor_off = text_wrap.editable.get_text_before().len() as u32; self.cursor_pos.set(atom, cursor_off); } - - /* - fn copy_highlighted(&self) -> Result<()> { - let start = self.selected.get_u32(0)? as usize; - let end = self.selected.get_u32(1)? as usize; - - let sel_start = std::cmp::min(start, end); - let sel_end = std::cmp::max(start, end); - - let mut text = String::new(); - - let glyphs = self.glyphs.lock().clone(); - for (glyph_idx, glyph) in glyphs.iter().enumerate() { - if sel_start <= glyph_idx && glyph_idx < sel_end { - text.push_str(&glyph.substr); - } - } - - info!(target: "ui::chatedit", "Copied '{}'", text); - window::clipboard_set(&text); - Ok(()) - } - - async fn paste_text(&self, key: String) { - let mut text = String::new(); - - let cursor_pos = self.cursor_pos.get(); - - if cursor_pos == 0 { - text = key.clone(); - } - - let glyphs = self.glyphs.lock().clone(); - for (glyph_idx, glyph) in glyphs.iter().enumerate() { - text.push_str(&glyph.substr); - if cursor_pos == glyph_idx as u32 + 1 { - text.push_str(&key); - } - } - - self.text.set(atom, text); - // Not always true lol - self.cursor_pos.set(atom, cursor_pos + 1); - - self.apply_cursor_scrolling(); - self.redraw().await; - } */ async fn handle_touch_start(&self, mut touch_pos: Point) -> bool { @@ -1452,6 +790,7 @@ impl ChatEdit { let rect = self.rect.get(); if !rect.contains(touch_pos) { + t!("rect!cont rect={rect:?}, touch_pos={touch_pos:?}"); return false } @@ -1459,59 +798,61 @@ impl ChatEdit { true } fn try_handle_drag(&self, touch_info: &mut TouchInfo, mut touch_pos: Point) -> bool { - // Is the handle visible? Use y within rect before adding the scroll. - let relative_y = touch_pos.y - self.rect.get().y; - if relative_y < 0. { - return false - } + /* + // Is the handle visible? Use y within rect before adding the scroll. + let relative_y = touch_pos.y - self.rect.get().y; + if relative_y < 0. { + return false + } - self.abs_to_local(&mut touch_pos); + self.abs_to_local(&mut touch_pos); - let linespacing = self.linespacing.get(); - let baseline = self.baseline.get(); - let select_descent = self.select_descent.get(); - let scroll = self.scroll.get(); + let linespacing = self.linespacing.get(); + let baseline = self.baseline.get(); + let select_descent = self.select_descent.get(); + let scroll = self.scroll.get(); - let mut text_wrap = self.text_wrap.lock(); - let width = self.wrap_width(); - let wrapped_lines = text_wrap.wrap(width); - let selections = &text_wrap.select; + let mut text_wrap = self.text_wrap.lock(); + let width = self.wrap_width(); + let wrapped_lines = text_wrap.wrap(width); + let selections = &text_wrap.select; - if self.is_phone_select.load(Ordering::Relaxed) && selections.len() == 1 { - let select = selections.first().unwrap(); + if self.is_phone_select.load(Ordering::Relaxed) && selections.len() == 1 { + let select = selections.first().unwrap(); - let handle_off_y = baseline + self.handle_descent.get(); + let handle_off_y = baseline + self.handle_descent.get(); - // Get left handle centerpoint - let (glyph_rect, line_idx) = wrapped_lines.get_glyph_info(select.start); - let mut p1 = glyph_rect.pos(); - // We always want the handles to be aligned so ignore the glyph's y pos - p1.y = line_idx as f32 * linespacing + handle_off_y; + // Get left handle centerpoint + let (glyph_rect, line_idx) = wrapped_lines.get_glyph_info(select.start); + let mut p1 = glyph_rect.pos(); + // We always want the handles to be aligned so ignore the glyph's y pos + p1.y = line_idx as f32 * linespacing + handle_off_y; - // Get right handle centerpoint - let (glyph_rect, line_idx) = wrapped_lines.get_glyph_info(select.end); - let mut p2 = glyph_rect.top_right(); - p2.y = line_idx as f32 * linespacing + handle_off_y; + // Get right handle centerpoint + let (glyph_rect, line_idx) = wrapped_lines.get_glyph_info(select.end); + let mut p2 = glyph_rect.top_right(); + p2.y = line_idx as f32 * linespacing + handle_off_y; - // Are we within range of either one? - t!("handle center points = ({p1:?}, {p2:?})"); + // Are we within range of either one? + t!("handle center points = ({p1:?}, {p2:?})"); - const TOUCH_RADIUS_SQ: f32 = 10_000.; + const TOUCH_RADIUS_SQ: f32 = 10_000.; - if p1.dist_sq(&touch_pos) <= TOUCH_RADIUS_SQ { - d!("start touch: DragSelectHandle state [side=-1]"); - // Set touch_state status to enable begin dragging them - touch_info.state = TouchStateAction::DragSelectHandle { side: -1 }; - return true; - } - if p2.dist_sq(&touch_pos) <= TOUCH_RADIUS_SQ { - d!("start touch: DragSelectHandle state [side=1]"); - // Set touch_state status to enable begin dragging them - touch_info.state = TouchStateAction::DragSelectHandle { side: 1 }; - return true; - } - } + if p1.dist_sq(&touch_pos) <= TOUCH_RADIUS_SQ { + d!("start touch: DragSelectHandle state [side=-1]"); + // Set touch_state status to enable begin dragging them + touch_info.state = TouchStateAction::DragSelectHandle { side: -1 }; + return true; + } + if p2.dist_sq(&touch_pos) <= TOUCH_RADIUS_SQ { + d!("start touch: DragSelectHandle state [side=1]"); + // Set touch_state status to enable begin dragging them + touch_info.state = TouchStateAction::DragSelectHandle { side: 1 }; + return true; + } + } + */ false } @@ -1544,6 +885,7 @@ impl ChatEdit { TouchStateAction::DragSelectHandle { side } => { self.abs_to_local(&mut touch_pos); { + /* let linespacing = self.linespacing.get(); let baseline = self.baseline.get(); let handle_descent = self.handle_descent.get(); @@ -1584,10 +926,12 @@ impl ChatEdit { } self.update_select_text(&mut text_wrap, atom); + */ } self.redraw().await; } TouchStateAction::ScrollVert { start_pos, scroll_start } => { + /* let max_scroll = { let mut text_wrap = self.text_wrap.lock(); self.max_scroll(&mut text_wrap) @@ -1601,12 +945,13 @@ impl ChatEdit { } self.scroll.set(atom, scroll); self.redraw().await; + */ } TouchStateAction::SetCursorPos => { // TBH I can't even see the cursor under my thumb so I'll just // comment this for now. - //self.abs_to_local(&mut touch_pos); - //self.touch_set_cursor_pos(touch_pos).await + self.abs_to_local(&mut touch_pos); + self.touch_set_cursor_pos(touch_pos, atom).await } _ => {} } @@ -1621,7 +966,8 @@ impl ChatEdit { match state { TouchStateAction::Inactive => return false, TouchStateAction::Started { pos: _, instant: _ } | TouchStateAction::SetCursorPos => { - self.touch_set_cursor_pos(touch_pos, atom).await + self.touch_set_cursor_pos(touch_pos, atom).await; + self.redraw().await; } _ => {} } @@ -1634,6 +980,100 @@ impl ChatEdit { async fn touch_set_cursor_pos(&self, mut touch_pos: Point, atom: &mut PropertyAtomicGuard) { t!("touch_set_cursor_pos({touch_pos:?})"); + + let mut editor = self.editor.lock().await; + editor.move_to_pos(touch_pos); + editor.refresh().await; + + //let layout = editor.layout(); + //let cursor = parley::Cursor::from_point(layout, touch_pos.x, touch_pos.y); + //drop(editor); + + //let cursor_idx = cursor.index(); + //let buffer = crate::android::get_raw_text().unwrap(); + //let cursor_clsr = byte_to_char16_index(&buffer, cursor_idx).unwrap(); + //crate::android::set_cursor_pos(cursor_clsr as i32); + //t!(" {cursor_idx} => {cursor_clsr}"); + + /* + // This is my own type that contains the editor and contexts. + let mut editor = self.editor.lock(); + t!(" editor (before): {editor:?}"); + + //if editor.editor.is_composing() { + // let mut drv = editor.driver(); + // // commit the existing compose text + // drv.finish_compose(); + // t!(" editor (finish_compose): {editor:?}"); + //} + + let mut drv = editor.driver(); + drv.move_to_point(touch_pos.x, touch_pos.y); + + let focus = editor.editor.raw_selection().focus(); + let layout = editor.layout(); + let idx = focus.index(); + + // Android uses chars, so we have to convert the byte index to a char index + let buffer = editor.editor.raw_text(); + let cursor_idx = byte_to_char16_index(buffer, idx).unwrap(); + t!(" idx = {idx} => cursor_idx = {cursor_idx}"); + crate::android::set_cursor_pos(cursor_idx as i32); + t!(" editor (after): {editor:?}"); + t!(" editable: {}", crate::android::get_debug_editable()); + */ + + // OLD + /* + let last_suggest_text = std::mem::take(&mut editor.last_suggest_text); + let is_composing = editor.editor.is_composing(); + if is_composing { + let mut drv = editor.driver(); + t!("clear and commit compose: {last_suggest_text}"); + // commit the existing compose text + drv.clear_compose(); + drv.insert_or_replace_selection(&last_suggest_text); + t!(" text: '{}'", editor.editor.raw_text()); + } + + let mut drv = editor.driver(); + // We move the cursor here first so we can get the offset within the composing word + drv.move_to_point(touch_pos.x, touch_pos.y); + drop(drv); + + let focus = editor.editor.raw_selection().focus(); + let layout = editor.layout(); + let start = focus.previous_logical_word(layout).index(); + let curr_idx = focus.index(); + assert!(curr_idx >= start); + let curr_off = curr_idx - start; + + let txt = editor.editor.raw_text(); + t!("{}[{}|{}", &txt[..start], &txt[start..curr_idx], &txt[curr_idx..]); + t!("start={start}, curr_idx={curr_idx}"); + + let mut drv = editor.driver(); + // Move the word under the cursor to the composer + drv.select_word_at_point(touch_pos.x, touch_pos.y); + let current_word = drv.editor.selected_text().unwrap().to_string(); + drv.delete_selection(); + t!("selected word: '{}|{}'", ¤t_word[..curr_off], ¤t_word[curr_off..]); + drv.set_compose(¤t_word, Some((curr_off, curr_off))); + drop(drv); + + let comp = editor.editor.compose().clone().unwrap(); + let txt = editor.editor.raw_text(); + let (pre, rest) = txt.split_at(comp.start); + let (cmp, post) = rest.split_at(comp.end - comp.start); + t!("after compose: '{}[{}]{}'", pre, cmp, post); + + // This will then trigger handlers that call drv.set_compose(...) + crate::android::set_compose(¤t_word, curr_off as i32); + //crate::android::set_compose(&"", 0); + editor.last_suggest_text = current_word; + */ + + /* let width = self.wrap_width(); { let mut text_wrap = self.text_wrap.lock(); @@ -1654,6 +1094,7 @@ impl ChatEdit { self.hide_cursor.store(false, Ordering::Relaxed); self.redraw().await; + */ } /// Whenever the cursor property is modified this MUST be called @@ -1707,6 +1148,7 @@ impl ChatEdit { self.scroll.set(atom, scroll); } + /* fn max_scroll(&self, text_wrap: &mut TextWrap) -> f32 { let width = self.wrap_width(); let mut inner_height = text_wrap.wrap(width).height(); @@ -1723,9 +1165,11 @@ impl ChatEdit { let max_scroll = inner_height - max_height; max_scroll.clamp(0., f32::MAX) } + */ /// When we resize the screen, the rect changes so we may need to alter the scroll. /// Or if we delete text. + /* fn clamp_scroll(&self, text_wrap: &mut TextWrap, atom: &mut PropertyAtomicGuard) { let max_scroll = self.max_scroll(text_wrap); let mut scroll = self.scroll.get(); @@ -1733,6 +1177,7 @@ impl ChatEdit { self.scroll.set(atom, max_scroll); } } + */ fn pause_blinking(&self) { self.blink_is_paused.store(true, Ordering::Relaxed); @@ -1744,16 +1189,16 @@ impl ChatEdit { let trace_id = rand::random(); let timest = unixtime(); t!("redraw()"); - let Some(draw_update) = self.make_draw_calls(trace_id, atom) else { + let Some(draw_update) = self.make_draw_calls(trace_id, atom).await else { error!(target: "ui::chatedit", "Text failed to draw"); return; }; self.render_api.replace_draw_calls(timest, draw_update.draw_calls); } - fn redraw_cursor(&self) { + async fn redraw_cursor(&self) { let timest = unixtime(); - let cursor_instrs = self.get_cursor_instrs(); + let cursor_instrs = self.get_cursor_instrs().await; let draw_calls = vec![( self.cursor_dc_key, GfxDrawCall { instrs: cursor_instrs, dcs: vec![], z_index: self.z_index.get() }, @@ -1761,7 +1206,7 @@ impl ChatEdit { self.render_api.replace_draw_calls(timest, draw_calls); } - fn get_cursor_instrs(&self) -> Vec { + async fn get_cursor_instrs(&self) -> Vec { if !self.is_focused.get() || !self.cursor_is_visible.load(Ordering::Relaxed) || self.hide_cursor.load(Ordering::Relaxed) @@ -1769,9 +1214,12 @@ impl ChatEdit { return vec![] } + let lineheight = self.lineheight.get(); + let mut cursor_instrs = vec![]; - let cursor_pos = self.get_cursor_pos(); + let Some(cursor_pos) = self.editor.lock().await.get_cursor_pos() else { return vec![] }; + //let cursor_pos = self.get_cursor_pos(); // There is some mess here since ApplyView is in abs screen coords but should be // relative, and also work together with Move. We will fix this later in gfx subsys. @@ -1794,12 +1242,37 @@ impl ChatEdit { cursor_instrs } - fn make_draw_calls(&self, trace_id: u32, atom: &mut PropertyAtomicGuard) -> Option { - let text_mesh = self.regen_text_mesh(trace_id, atom); - let cursor_instrs = self.get_cursor_instrs(); + async fn regen_mesh(&self) -> Vec { + 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 editor = self.editor.lock().await; + let layout = editor.layout(); + text2::render_layout(layout, &self.render_api) + } + + async fn make_draw_calls( + &self, + trace_id: u32, + atom: &mut PropertyAtomicGuard, + ) -> Option { + let parent_rect = self.parent_rect.lock().clone().unwrap(); + self.rect.eval_with( + vec![2], + vec![("parent_w".to_string(), parent_rect.w), ("parent_h".to_string(), parent_rect.h)], + ); + self.rect.prop().set_f32(atom, Role::Internal, 3, 2000.); + + //let text_mesh = self.regen_text_mesh(trace_id, atom); + let cursor_instrs = self.get_cursor_instrs().await; let rect = self.rect.get(); + let mut instrs = vec![GfxDrawInstruction::Move(rect.pos())]; + instrs.append(&mut self.regen_mesh().await); + Some(DrawUpdate { key: self.main_dc_key, draw_calls: vec![ @@ -1813,14 +1286,7 @@ impl ChatEdit { ), ( self.text_dc_key, - GfxDrawCall { - instrs: vec![ - GfxDrawInstruction::Move(rect.pos()), - GfxDrawInstruction::Draw(text_mesh), - ], - dcs: vec![], - z_index: self.z_index.get(), - }, + GfxDrawCall { instrs, dcs: vec![], z_index: self.z_index.get() }, ), ( self.cursor_dc_key, @@ -1856,9 +1322,31 @@ impl ChatEdit { }; let atom = &mut PropertyAtomicGuard::new(); - self_.insert_text(&text, atom).await; + //self_.insert_text(&text, atom).await; true } + + async fn handle_android_event(&self, ev: AndroidSuggestEvent) { + t!("handle_android_event({ev:?})"); + if !self.is_active.get() { + return + } + + let mut editor = self.editor.lock().await; + match ev { + AndroidSuggestEvent::Init => { + editor.init(); + return + } + AndroidSuggestEvent::CreateInputConnect => editor.setup(), + _ => {} //editor.update(text.clone(), select_start, select_end, compose_start, compose_end), + } + + editor.refresh().await; + drop(editor); + + self.redraw().await; + } } impl Drop for ChatEdit { @@ -1896,7 +1384,7 @@ impl UIObject for ChatEdit { self_.cursor_pos.set(atom, 0); //self_.select_text.set_null(Role::Internal, 0).unwrap(); self_.scroll.set(atom, 0.); - self_.redraw(); + self_.redraw().await; } async fn redraw(self_: Arc) { self_.redraw().await; @@ -1905,13 +1393,13 @@ impl UIObject for ChatEdit { { let text = self_.text.get(); - let mut text_wrap = self_.text_wrap.lock(); - text_wrap.editable.end_compose(); - text_wrap.editable.set_text(text, String::new()); - text_wrap.clear_cache(); + //let mut text_wrap = self_.text_wrap.lock(); + //text_wrap.editable.end_compose(); + //text_wrap.editable.set_text(text, String::new()); + //text_wrap.clear_cache(); - let select = &mut text_wrap.select; - select.clear(); + //let select = &mut text_wrap.select; + //select.clear(); } self_.redraw().await; @@ -1920,6 +1408,7 @@ impl UIObject for ChatEdit { on_modify.when_change(self.rect.prop(), redraw); on_modify.when_change(self.baseline.prop(), redraw); on_modify.when_change(self.linespacing.prop(), redraw); + on_modify.when_change(self.lineheight.prop(), redraw); on_modify.when_change(self.select_ascent.prop(), redraw); on_modify.when_change(self.select_descent.prop(), redraw); on_modify.when_change(self.handle_descent.prop(), redraw); @@ -1967,12 +1456,39 @@ impl UIObject for ChatEdit { // Invert the bool self_.cursor_is_visible.fetch_not(Ordering::Relaxed); - self_.redraw_cursor(); + self_.redraw_cursor().await; } }); let mut tasks = vec![insert_text_task, blinking_cursor_task]; tasks.append(&mut on_modify.tasks); + + #[cfg(target_os = "android")] + { + let (sender, recvr) = async_channel::unbounded(); + let composer_id = crate::android::create_composer(sender); + self.editor.lock().await.composer_id = composer_id; + t!("Created composer [{composer_id}]"); + + 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 autosuggest_task was stopped!"); + }; + + self_.handle_android_event(ev).await; + } + }); + tasks.push(autosuggest_task); + } + self.tasks.set(tasks); } @@ -1985,7 +1501,7 @@ impl UIObject for ChatEdit { t!("ChatEdit::draw({:?}, {trace_id})", self.node.upgrade().unwrap()); *self.parent_rect.lock() = Some(parent_rect); - self.make_draw_calls(trace_id, atom) + self.make_draw_calls(trace_id, atom).await } async fn handle_char(&self, key: char, mods: KeyMods, repeat: bool) -> bool { @@ -2019,8 +1535,9 @@ impl UIObject for ChatEdit { let atom = &mut PropertyAtomicGuard::new(); t!("Key {:?} has {} actions", key, actions); for _ in 0..actions { - self.insert_char(key, atom).await; + self.insert_char(key).await; } + self.redraw().await; true } @@ -2096,6 +1613,7 @@ impl UIObject for ChatEdit { let width = self.wrap_width(); + /* { let mut text_wrap = self.text_wrap.lock(); let cursor_pos = text_wrap.set_cursor_with_point(mouse_pos, width); @@ -2112,6 +1630,7 @@ impl UIObject for ChatEdit { self.mouse_btn_held.store(true, Ordering::Relaxed); } + */ self.pause_blinking(); self.redraw().await; @@ -2152,6 +1671,7 @@ impl UIObject for ChatEdit { let width = self.wrap_width(); + /* { let mut text_wrap = self.text_wrap.lock(); let cursor_pos = text_wrap.set_cursor_with_point(mouse_pos, width); @@ -2165,6 +1685,7 @@ impl UIObject for ChatEdit { select.first_mut().unwrap().end = cursor_pos; self.update_select_text(&mut text_wrap, atom); } + */ self.pause_blinking(); //self.apply_cursor_scrolling(); @@ -2179,6 +1700,7 @@ impl UIObject for ChatEdit { let atom = &mut PropertyAtomicGuard::new(); + /* let max_scroll = { let mut text_wrap = self.text_wrap.lock(); self.max_scroll(&mut text_wrap) @@ -2189,6 +1711,7 @@ impl UIObject for ChatEdit { t!("handle_mouse_wheel({wheel_pos:?}) [scroll={scroll}]"); self.scroll.set(atom, scroll); self.redraw().await; + */ true } @@ -2210,45 +1733,4 @@ impl UIObject for ChatEdit { TouchPhase::Cancelled => false, } } - - async fn handle_compose_text(&self, suggest_text: &str, is_commit: bool) -> bool { - t!("handle_compose_text({suggest_text}, {is_commit})"); - let atom = &mut PropertyAtomicGuard::new(); - - if !self.is_active.get() { - return false - } - - let text = { - let mut text_wrap = self.text_wrap.lock(); - text_wrap.clear_cache(); - text_wrap.editable.compose(suggest_text, is_commit); - - self.clamp_scroll(&mut text_wrap, atom); - text_wrap.editable.get_text() - }; - self.text.set(atom, text); - - //self.apply_cursor_scrolling(); - self.redraw().await; - - true - } - async fn handle_set_compose_region(&self, start: usize, end: usize) -> bool { - t!("handle_set_compose_region({start}, {end})"); - - if !self.is_active.get() { - return false - } - - { - let mut text_wrap = self.text_wrap.lock(); - text_wrap.clear_cache(); - text_wrap.editable.set_compose_region(start, end); - } - - self.redraw().await; - - true - } } diff --git a/bin/app/src/ui/editbox/editable.rs b/bin/app/src/ui/editbox/editable.rs index 0f27979dd..eeb1091ca 100644 --- a/bin/app/src/ui/editbox/editable.rs +++ b/bin/app/src/ui/editbox/editable.rs @@ -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(); diff --git a/bin/app/src/ui/editbox/mod.rs b/bin/app/src/ui/editbox/mod.rs index 6892fa465..03aa532d8 100644 --- a/bin/app/src/ui/editbox/mod.rs +++ b/bin/app/src/ui/editbox/mod.rs @@ -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 diff --git a/bin/app/src/ui/layer.rs b/bin/app/src/ui/layer.rs index fe32d125b..0dc8ffc0c 100644 --- a/bin/app/src/ui/layer.rs +++ b/bin/app/src/ui/layer.rs @@ -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 - } } diff --git a/bin/app/src/ui/mod.rs b/bin/app/src/ui/mod.rs index bd27f2eff..24461975b 100644 --- a/bin/app/src/ui/mod.rs +++ b/bin/app/src/ui/mod.rs @@ -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 { diff --git a/bin/app/src/ui/text.rs b/bin/app/src/ui/text.rs index f64555423..81677f675 100644 --- a/bin/app/src/ui/text.rs +++ b/bin/app/src/ui/text.rs @@ -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 { + async fn regen_mesh(&self) -> Vec { 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 = 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) { @@ -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, diff --git a/bin/app/src/ui/win.rs b/bin/app/src/ui/win.rs index 701391fc3..f1c6a2a14 100644 --- a/bin/app/src/ui/win.rs +++ b/bin/app/src/ui/win.rs @@ -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(); diff --git a/script/research/codes/reed-solomon.sage b/script/research/codes/reed-solomon.sage index cf03294de..ddbf82571 100644 --- a/script/research/codes/reed-solomon.sage +++ b/script/research/codes/reed-solomon.sage @@ -7,6 +7,7 @@ K = GF(q) F. = K[] f = 4 + 3*z + 1*z^2 + 5*z^3 +assert len(list(f)) == k # Let βᵢ = K(i - 1)