Browse Source

app/edit: fixes for android text editing

darkfi 1 week ago
parent
commit
f03f3e5c54

+ 18 - 0
bin/app/README.md

@@ -34,6 +34,24 @@ Users who prefer to build locally can follow the commands in the `Dockerfile`.
 Note that the `build.rs` hardcodes the SDK/NDK paths so either you follow it
 exactly (recommended) or modify `build.rs`.
 
+# ADB Over Wifi
+
+Useful for reading the logs without having to be plugged in.
+First get your local IP addr using `adb shell ip -f inet a show wlan0`.
+Make sure "Wireless debugging" is enabled in Developer options.
+Then run:
+
+```
+adb tcpip 5555
+adb connect IPADDR
+```
+
+Copying the APK takes a long time over wifi so best to install
+APK via USB, then use this just for debugging the app.
+
+In the Makefile, make sure to put the USB device for `ADB_DEVICES`
+so the ADB commands work over USB.
+
 # Useful Dev Commands
 
 This is just for devs. Users ignore this.

+ 71 - 11
bin/app/src/android/textinput/gametextinput.rs

@@ -30,6 +30,33 @@ macro_rules! w { ($($arg:tt)*) => { warn!(target: "android::textinput::gametexti
 
 pub const SPAN_UNDEFINED: i32 = -1;
 
+/// Rust byte index -> Java UTF-16 code-unit index.
+/// `byte_idx` must be a valid char boundary of `text`.
+fn byte_to_utf16(text: &str, byte_idx: usize) -> usize {
+    assert!(
+        text.is_char_boundary(byte_idx),
+        "byte_idx {byte_idx} is not a char boundary of {text:?}"
+    );
+    text[..byte_idx].chars().map(|c| c.len_utf16()).sum()
+}
+
+/// Java UTF-16 code-unit index -> Rust byte index.
+/// `utf16_idx` must land exactly on a char boundary (splits a surrogate pair otherwise).
+fn utf16_to_byte(text: &str, utf16_idx: usize) -> usize {
+    let mut count = 0usize;
+    for (byte_idx, ch) in text.char_indices() {
+        if count == utf16_idx {
+            return byte_idx;
+        }
+        count += ch.len_utf16();
+    }
+    assert!(
+        count == utf16_idx,
+        "utf16_idx {utf16_idx} is not at a char boundary (text utf16 len = {count})"
+    );
+    text.len()
+}
+
 /// Global GameTextInput instance for JNI bridge
 ///
 /// Single global instance since only ONE editor is active at a time.
@@ -205,11 +232,13 @@ impl GameTextInput {
         }
     }
 
-    pub fn set_select(&self, start: i32, end: i32) -> Result<(), ()> {
+    pub fn set_select(&self, text: &str, start: usize, end: usize) -> Result<(), ()> {
         let Some(input_connection) = *self.input_connection.read() else {
             w!("push_update() - no input_connection set");
             return Err(())
         };
+        let start = byte_to_utf16(text, start) as i32;
+        let end = byte_to_utf16(text, end) as i32;
         let is_success = unsafe {
             let env = get_jni_env();
             call_bool_method!(env, input_connection, "setSelection", "(II)Z", start, end)
@@ -321,7 +350,10 @@ impl GameTextInput {
             let new_object = (**env).NewObject.unwrap();
 
             let (compose_start, compose_end) = match state.compose {
-                Some((start, end)) => (start as i32, end as i32),
+                Some((start, end)) => (
+                    byte_to_utf16(&state.text, start) as i32,
+                    byte_to_utf16(&state.text, end) as i32,
+                ),
                 None => (SPAN_UNDEFINED, SPAN_UNDEFINED),
             };
 
@@ -330,8 +362,8 @@ impl GameTextInput {
                 self.state_class,
                 self.state_constructor,
                 jtext,
-                state.select.0 as i32,
-                state.select.1 as i32,
+                byte_to_utf16(&state.text, state.select.0) as i32,
+                byte_to_utf16(&state.text, state.select.1) as i32,
                 compose_start,
                 compose_end,
             );
@@ -363,18 +395,46 @@ impl GameTextInput {
             let delete_local_ref = (**env).DeleteLocalRef.unwrap();
             delete_local_ref(env, jtext);
 
+            // Android reports -1 (SPAN_UNDEFINED) when there is no selection
+            // or composing region set (see android.text.Selection.getSelectionStart:
+            // "-1 if there is no selection or cursor"). During editor focus
+            // transitions / IME restarts it can also deliver momentarily
+            // inconsistent snapshots (e.g. stale selection indices referencing
+            // a position beyond the current text). Treat any out-of-range
+            // select as undefined -> cursor at end, mirroring the Java-side
+            // convention in InputConnection.processKeyEvent.
+            let utf16_len = text.encode_utf16().count();
+
+            let select = if select_start < 0 ||
+                select_end < 0 ||
+                select_start as usize > utf16_len ||
+                select_end as usize > utf16_len
+            {
+                (text.len(), text.len())
+            } else {
+                (
+                    utf16_to_byte(&text, select_start as usize),
+                    utf16_to_byte(&text, select_end as usize),
+                )
+            };
+
             let compose = if compose_start >= 0 {
-                Some((compose_start as usize, compose_end as usize))
+                assert!(
+                    (compose_start as usize) <= utf16_len
+                        && (compose_end as usize) <= utf16_len,
+                    "out-of-range compose ({compose_start}, {compose_end}) for utf16 len {utf16_len}"
+                );
+                Some((
+                    utf16_to_byte(&text, compose_start as usize),
+                    utf16_to_byte(&text, compose_end as usize),
+                ))
             } else {
-                assert!(compose_end < 0);
+                assert_eq!(compose_start, SPAN_UNDEFINED);
+                assert_eq!(compose_end, SPAN_UNDEFINED);
                 None
             };
 
-            AndroidTextInputState {
-                text,
-                select: (select_start as usize, select_end as usize),
-                compose,
-            }
+            AndroidTextInputState { text, select, compose }
         }
     }
 }

+ 5 - 4
bin/app/src/android/textinput/mod.rs

@@ -116,17 +116,18 @@ impl AndroidTextInput {
         //t!("set_select({select_start}, {select_end})");
         // Always update our own state.
         let mut ours = self.state.lock();
+        let is_active = ours.is_active;
+        let text = ours.state.text.clone();
         let state = &mut ours.state;
-        assert!(select_start <= state.text.len());
-        assert!(select_end <= state.text.len());
+        assert!(select_start <= text.len());
+        assert!(select_end <= text.len());
         state.select = (select_start, select_end);
-        let is_active = ours.is_active;
         drop(ours);
 
         // Only update java state when this input is active
         if is_active {
             let gti = GAME_TEXT_INPUT.get().unwrap();
-            gti.set_select(select_start as i32, select_end as i32).unwrap();
+            gti.set_select(&text, select_start, select_end).unwrap();
         }
     }
 }

+ 2 - 1
bin/app/src/text/editor/android.rs

@@ -73,7 +73,8 @@ impl Editor {
     pub fn on_text_prop_changed(&mut self) {
         // Update GameTextInput state
         self.state.text = self.text.get();
-        self.state.select = (0, 0);
+        let text_len = self.state.text.len();
+        self.state.select = (text_len, text_len);
         self.state.compose = None;
         self.input.set_state(self.state.clone());
         // Refresh our layout

+ 10 - 0
bin/app/src/ui/edit/mod.rs

@@ -1441,6 +1441,7 @@ impl BaseEdit {
         t!("handle_android_event({state:?})");
         let atom = &mut self.renderer.make_guard(gfxtag!("BaseEdit::handle_android_event"));
 
+        let is_new_select_collapsed = state.select.0 == state.select.1;
         let (is_text_changed, is_select_changed, is_compose_changed) = {
             let mut editor = self.editor.lock();
             // Diff old and new state so we know what changed
@@ -1482,6 +1483,15 @@ impl BaseEdit {
             self.finish_select(atom);
             self.redraw(atom);
         } else if is_select_changed {
+            // The IME can collapse a phone-style word selection out from under
+            // us (e.g. it repositions the cursor when the keyboard is shown).
+            // The selection handles are then stale, so finish phone-select mode.
+            // A handle drag always maintains a range, so its IME echoes are
+            // unaffected.
+            if is_new_select_collapsed && self.is_phone_select.load(Ordering::Relaxed) {
+                d!("IME has collapsed selection!");
+                self.finish_select(atom);
+            }
             // Redrawing the entire text just for select changes is expensive
             self.redraw_cursor(&self.renderer);
             //t!("handle_android_event calling redraw_select");