app: remove old text input stuff and replace with new game text input

This commit is contained in:
jkds
2026-01-01 12:18:23 +01:00
parent 0a5bc2772c
commit c84f3fcb09
4 changed files with 71 additions and 434 deletions

View File

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

View File

@@ -66,6 +66,12 @@ pub struct AndroidTextInput {
state: *mut GameTextInput,
}
// SAFETY: GameTextInput is accessed synchronously through show_ime/hide_ime/set_state/get_state
// The pointer is valid for the lifetime of the AndroidTextInput instance and is only
// accessed from the thread that owns the AndroidTextInput.
unsafe impl Send for AndroidTextInput {}
unsafe impl Sync for AndroidTextInput {}
impl AndroidTextInput {
pub fn new(sender: async_channel::Sender<AndroidTextInputState>) -> Self {
let id = {

View File

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

View File

@@ -37,7 +37,7 @@ use std::{
use tracing::instrument;
#[cfg(target_os = "android")]
use crate::android::AndroidSuggestEvent;
use crate::android::textinput::AndroidTextInputState;
use crate::{
gfx::{gfxtag, DrawCall, DrawInstruction, DrawMesh, Point, Rectangle, RenderApi, Vertex},
mesh::MeshBuilder,
@@ -1296,49 +1296,26 @@ impl BaseEdit {
}
#[cfg(target_os = "android")]
async fn handle_android_event(&self, ev: AndroidSuggestEvent) {
async fn handle_android_event(&self, state: AndroidTextInputState) {
if !self.is_active.get() {
return
}
t!("handle_android_event({ev:?})");
t!("handle_android_event({state:?})");
let atom = &mut self.render_api.make_guard(gfxtag!("BaseEdit::handle_android_event"));
match ev {
AndroidSuggestEvent::Init => {
let mut editor = self.lock_editor().await;
editor.init();
// For debugging select, enable these and set a selection in the editor.
//self.is_phone_select.store(true, Ordering::Relaxed);
//self.hide_cursor.store(true, Ordering::Relaxed);
// Debug code if we set text in editor.init()
//editor.on_buffer_changed(&mut PropertyAtomicGuard::none()).await;
return
}
AndroidSuggestEvent::CreateInputConnect => {
let mut editor = self.lock_editor().await;
editor.setup();
}
// Destructive text edits
AndroidSuggestEvent::ComposeRegion { .. } |
AndroidSuggestEvent::Compose { .. } |
AndroidSuggestEvent::DeleteSurroundingText { .. } => {
// Any editing will collapse selections
self.finish_select(atom);
let mut editor = self.lock_editor().await;
editor.on_buffer_changed(atom).await;
drop(editor);
self.eval_rect().await;
self.behave.apply_cursor_scroll().await;
}
AndroidSuggestEvent::FinishCompose => {
let mut editor = self.lock_editor().await;
editor.on_buffer_changed(atom).await;
}
// Text changed - finish any active selection
if state.text != self.text.get() {
self.finish_select(atom);
}
let mut editor = self.lock_editor().await;
editor.on_buffer_changed(atom).await;
drop(editor);
self.eval_rect().await;
self.behave.apply_cursor_scroll().await;
// Only redraw once we have the parent_rect
// Can happen when we receive an Android event before the canvas is ready
if self.parent_rect.lock().is_some() {