From 96df78ddef4decdd4cd0c45cbf39b3c878dc6db2 Mon Sep 17 00:00:00 2001 From: Robert Bragg Date: Mon, 12 Sep 2022 17:14:49 +0100 Subject: [PATCH] Expose TextEvent and input method state This also adds `InputEvent::TextEvent` for notifying applications of IME state changes as well as explicit getter/setter APIs for tracking IME selection + compose region state. (only supported with GameActivity) Fixes: #18 --- android-activity/Cargo.toml | 1 + .../native_app_glue/android_native_app_glue.c | 1 + android-activity/src/game_activity/input.rs | 1 + android-activity/src/game_activity/mod.rs | 128 +++++++++++++++++- android-activity/src/input.rs | 25 ++++ android-activity/src/lib.rs | 12 +- android-activity/src/native_activity/mod.rs | 23 ++++ 7 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 android-activity/src/input.rs diff --git a/android-activity/Cargo.toml b/android-activity/Cargo.toml index d992481..5a7aa52 100644 --- a/android-activity/Cargo.toml +++ b/android-activity/Cargo.toml @@ -24,6 +24,7 @@ native-activity = [] [dependencies] log = "0.4" jni-sys = "0.3" +cesu8 = "1" ndk = "0.7" ndk-sys = "0.4" ndk-context = "0.1" diff --git a/android-activity/game-activity-csrc/game-activity/native_app_glue/android_native_app_glue.c b/android-activity/game-activity-csrc/game-activity/native_app_glue/android_native_app_glue.c index 5fd3eb8..3640d7c 100644 --- a/android-activity/game-activity-csrc/game-activity/native_app_glue/android_native_app_glue.c +++ b/android-activity/game-activity-csrc/game-activity/native_app_glue/android_native_app_glue.c @@ -601,6 +601,7 @@ static void onTextInputEvent(GameActivity* activity, pthread_mutex_lock(&android_app->mutex); android_app->textInputState = 1; + notifyInput(android_app); pthread_mutex_unlock(&android_app->mutex); } diff --git a/android-activity/src/game_activity/input.rs b/android-activity/src/game_activity/input.rs index d75bc96..4d5f5d3 100644 --- a/android-activity/src/game_activity/input.rs +++ b/android-activity/src/game_activity/input.rs @@ -26,6 +26,7 @@ use bitflags::bitflags; pub enum InputEvent { MotionEvent(MotionEvent), KeyEvent(KeyEvent), + TextEvent(crate::input::TextInputState), } /// An enum representing the source of an [`MotionEvent`] or [`KeyEvent`] diff --git a/android-activity/src/game_activity/mod.rs b/android-activity/src/game_activity/mod.rs index 47e4912..f81b477 100644 --- a/android-activity/src/game_activity/mod.rs +++ b/android-activity/src/game_activity/mod.rs @@ -5,7 +5,7 @@ use std::fs::File; use std::io::{BufRead, BufReader}; use std::marker::PhantomData; use std::ops::Deref; -use std::os::raw; +use std::os::raw::{self, c_void}; use std::os::unix::prelude::*; use std::ptr::NonNull; use std::sync::{Arc, RwLock}; @@ -28,6 +28,7 @@ use crate::{util, AndroidApp, ConfigurationRef, MainEvent, PollEvent, Rect, Wind mod ffi; pub mod input; +use crate::input::{TextInputState, TextSpan}; use input::{Axis, InputEvent, KeyEvent, MotionEvent}; // The only time it's safe to update the android_app->savedState pointer is @@ -346,6 +347,122 @@ impl AndroidAppInner { } } + unsafe extern "C" fn map_input_state_to_text_event_callback( + context: *mut c_void, + state: *const ffi::GameTextInputState, + ) { + // Java uses a modified UTF-8 format, which is a modified cesu8 format + let out_ptr: *mut TextInputState = context.cast(); + let text_modified_utf8: *const u8 = (*state).text_UTF8.cast(); + let text_modified_utf8 = + std::slice::from_raw_parts(text_modified_utf8, (*state).text_length as usize); + match cesu8::from_java_cesu8(&text_modified_utf8) { + Ok(str) => { + (*out_ptr).text = String::from(str); + (*out_ptr).selection = TextSpan { + start: match (*state).selection.start { + -1 => None, + off => Some(off as usize), + }, + end: match (*state).selection.end { + -1 => None, + off => Some(off as usize), + }, + }; + (*out_ptr).compose_region = TextSpan { + start: match (*state).composingRegion.start { + -1 => None, + off => Some(off as usize), + }, + end: match (*state).composingRegion.end { + -1 => None, + off => Some(off as usize), + }, + }; + } + Err(err) => { + log::error!("Invalid UTF8 text in TextEvent: {}", err); + } + } + } + + // TODO: move into a trait + pub fn text_input_state(&self) -> TextInputState { + unsafe { + let activity = (*self.native_app.as_ptr()).activity; + let mut out_state = TextInputState { + text: String::new(), + selection: TextSpan { + start: None, + end: None, + }, + compose_region: TextSpan { + start: None, + end: None, + }, + }; + let out_ptr = &mut out_state as *mut TextInputState; + + // NEON WARNING: + // + // It's not clearly documented but the GameActivity API over the + // GameTextInput library directly exposes _modified_ UTF8 text + // from Java so we need to be careful to convert text to and + // from UTF8 + // + // GameTextInput also uses a pre-allocated, fixed-sized buffer for the current + // text state but GameTextInput doesn't actually provide it's own thread + // safe API to safely access this state so we have to cooperate with + // the GameActivity code that does locking when reading/writing the state + // (I.e. we can't just punch through to the GameTextInput layer from here). + // + // Overall this is all quite gnarly - and probably a good reminder of why + // we want to use Rust instead of C/C++. + ffi::GameActivity_getTextInputState( + activity, + Some(AndroidAppInner::map_input_state_to_text_event_callback), + out_ptr.cast(), + ); + + out_state + } + } + + // TODO: move into a trait + pub fn set_text_input_state(&self, state: TextInputState) { + unsafe { + let activity = (*self.native_app.as_ptr()).activity; + let modified_utf8 = cesu8::to_java_cesu8(&state.text); + let text_length = modified_utf8.len() as i32; + let modified_utf8_bytes = modified_utf8.as_ptr(); + let ffi_state = ffi::GameTextInputState { + text_UTF8: modified_utf8_bytes.cast(), // NB: may be signed or unsigned depending on target + text_length, + selection: ffi::GameTextInputSpan { + start: match state.selection.start { + Some(off) => off as i32, + None => -1, + }, + end: match state.selection.end { + Some(off) => off as i32, + None => -1, + }, + }, + composingRegion: ffi::GameTextInputSpan { + start: match state.compose_region.start { + Some(off) => off as i32, + None => -1, + }, + end: match state.compose_region.end { + Some(off) => off as i32, + None => -1, + }, + }, + }; + ffi::GameActivity_setTextInputState(activity, &ffi_state as *const _); + } + } + pub fn enable_motion_axis(&mut self, axis: Axis) { unsafe { ffi::GameActivityPointerAxes_enableAxis(axis as i32) } } @@ -408,6 +525,15 @@ impl AndroidAppInner { for motion_event in buf.motion_events_iter() { callback(&InputEvent::MotionEvent(motion_event)); } + + unsafe { + let app_ptr = self.native_app.as_ptr(); + if (*app_ptr).textInputState != 0 { + let state = self.text_input_state(); + callback(&InputEvent::TextEvent(state)); + (*app_ptr).textInputState = 0; + } + } } pub fn internal_data_path(&self) -> Option { diff --git a/android-activity/src/input.rs b/android-activity/src/input.rs new file mode 100644 index 0000000..9e9dfec --- /dev/null +++ b/android-activity/src/input.rs @@ -0,0 +1,25 @@ +/// This struct holds a span within a region of text from `start` (inclusive) to +/// `end` (exclusive). +/// +/// An empty span or cursor position is specified with `Some(start) == Some(end)`. +/// +/// An undefined span is specified with start = end = `None`. +#[derive(Debug, Clone, Copy)] +pub struct TextSpan { + /// The start of the span (inclusive) + pub start: Option, + + /// The end of the span (exclusive) + pub end: Option, +} + +#[derive(Debug, Clone)] +pub struct TextInputState { + pub text: String, + /// A selection defined on the text. + pub selection: TextSpan, + /// A composing region defined on the text. + pub compose_region: TextSpan, +} + +pub use crate::activity_impl::input::*; diff --git a/android-activity/src/lib.rs b/android-activity/src/lib.rs index 8e559d0..e6547ab 100644 --- a/android-activity/src/lib.rs +++ b/android-activity/src/lib.rs @@ -47,7 +47,7 @@ mod game_activity; #[cfg(feature = "game-activity")] use game_activity as activity_impl; -pub use activity_impl::input; +pub mod input; mod config; pub use config::ConfigurationRef; @@ -471,6 +471,16 @@ impl AndroidApp { .hide_soft_input(hide_implicit_only); } + /// Fetch the current input text state, as updated by any active IME. + pub fn text_input_state(&self) -> input::TextInputState { + self.inner.read().unwrap().text_input_state() + } + + /// Forward the given input text `state` to any active IME. + pub fn set_text_input_state(&self, state: input::TextInputState) { + self.inner.read().unwrap().set_text_input_state(state); + } + /// Query and process all out-standing input event /// /// Applications are generally either expected to call this in-sync with their rendering or diff --git a/android-activity/src/native_activity/mod.rs b/android-activity/src/native_activity/mod.rs index af2cbf2..a55f45d 100644 --- a/android-activity/src/native_activity/mod.rs +++ b/android-activity/src/native_activity/mod.rs @@ -21,6 +21,7 @@ use ndk::configuration::Configuration; use ndk::input_queue::InputQueue; use ndk::native_window::NativeWindow; +use crate::input::{TextInputState, TextSpan}; use crate::{util, AndroidApp, ConfigurationRef, MainEvent, PollEvent, Rect, WindowManagerFlags}; mod ffi; @@ -39,6 +40,7 @@ pub mod input { pub enum InputEvent { MotionEvent(self::MotionEvent), KeyEvent(self::KeyEvent), + TextEvent(crate::input::TextInputState), } } @@ -405,6 +407,26 @@ impl AndroidAppInner { } } + // TODO: move into a trait + pub fn text_input_state(&self) -> TextInputState { + TextInputState { + text: String::new(), + selection: TextSpan { + start: None, + end: None, + }, + compose_region: TextSpan { + start: None, + end: None, + }, + } + } + + // TODO: move into a trait + pub fn set_text_input_state(&self, _state: TextInputState) { + // NOP: Unsupported + } + pub fn enable_motion_axis(&self, _axis: input::Axis) { // NOP - The InputQueue API doesn't let us optimize which axis values are read } @@ -449,6 +471,7 @@ impl AndroidAppInner { let ndk_event = match event { input::InputEvent::MotionEvent(e) => ndk::event::InputEvent::MotionEvent(e), input::InputEvent::KeyEvent(e) => ndk::event::InputEvent::KeyEvent(e), + _ => unreachable!(), }; // Always report events as 'handled'. This means we won't get