Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Input method (soft keyboard) support #24

Merged
merged 1 commit into from
Aug 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android-activity/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ native-activity = []
[dependencies]
log = "0.4"
jni-sys = "0.3"
cesu8 = "1"
ndk = "0.7"
ndk-sys = "0.4"
ndk-context = "0.1"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,7 @@ static void onTextInputEvent(GameActivity* activity,
pthread_mutex_lock(&android_app->mutex);
if (!android_app->destroyed) {
android_app->textInputState = 1;
notifyInput(android_app);
}
pthread_mutex_unlock(&android_app->mutex);
}
Expand Down
1 change: 1 addition & 0 deletions android-activity/src/game_activity/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ use crate::input::{Class, Source};
pub enum InputEvent<'a> {
MotionEvent(MotionEvent<'a>),
KeyEvent(KeyEvent<'a>),
TextEvent(crate::input::TextInputState),
}

/// A bitfield representing the state of modifier keys during an event.
Expand Down
140 changes: 139 additions & 1 deletion android-activity/src/game_activity/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ use crate::{
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
Expand Down Expand Up @@ -360,6 +361,121 @@ 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) => {
let len = *&str.len();
(*out_ptr).text = String::from(str);

let selection_start = (*state).selection.start.clamp(0, len as i32 + 1);
let selection_end = (*state).selection.end.clamp(0, len as i32 + 1);
(*out_ptr).selection = TextSpan {
start: selection_start as usize,
end: selection_end as usize,
};
if (*state).composingRegion.start < 0 || (*state).composingRegion.end < 0 {
(*out_ptr).compose_region = None;
} else {
(*out_ptr).compose_region = Some(TextSpan {
start: (*state).composingRegion.start as usize,
end: (*state).composingRegion.end 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: 0, end: 0 },
compose_region: 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: state.selection.start as i32,
end: state.selection.end as i32,
},
composingRegion: match state.compose_region {
Some(span) => {
// The GameText subclass of InputConnection only has a special case for removing the
// compose region if `start == -1` but the docs for `setComposingRegion` imply that
// the region should effectively be removed if any empty region is given (unlike for the
// selection region, it's not meaningful to maintain an empty compose region)
//
// We aim for more consistent behaviour by normalizing any empty region into `(-1, -1)`
// to remove the compose region.
//
// NB `setComposingRegion` itself is documented to clamp start/end to the text bounds
// so apart from this special-case handling in GameText's implementation of
// `setComposingRegion` then there's nothing special about `(-1, -1)` - it's just an empty
// region that should get clamped to `(0, 0)` and then get removed.
if span.start == span.end {
ffi::GameTextInputSpan { start: -1, end: -1 }
} else {
ffi::GameTextInputSpan {
start: span.start as i32,
end: span.end as i32,
}
}
}
None => ffi::GameTextInputSpan { start: -1, end: -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) }
}
Expand Down Expand Up @@ -403,7 +519,7 @@ impl AndroidAppInner {
}
}

pub fn input_events<F>(&self, mut callback: F)
fn dispatch_key_and_motion_events<F>(&self, mut callback: F)
where
F: FnMut(&InputEvent) -> InputStatus,
{
Expand All @@ -426,6 +542,28 @@ impl AndroidAppInner {
}
}

fn dispatch_text_events<F>(&self, mut callback: F)
where
F: FnMut(&InputEvent) -> InputStatus,
{
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 input_events<F>(&self, mut callback: F)
where
F: FnMut(&InputEvent) -> InputStatus,
{
self.dispatch_key_and_motion_events(&mut callback);
self.dispatch_text_events(&mut callback);
}

pub fn internal_data_path(&self) -> Option<std::path::PathBuf> {
unsafe {
let app_ptr = self.native_app.as_ptr();
Expand Down
38 changes: 38 additions & 0 deletions android-activity/src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,41 @@ impl From<Source> for Class {
source.into()
}
}

/// This struct holds a span within a region of text from `start` to `end`.
///
/// The `start` index may be greater than the `end` index (swapping `start` and `end` will represent the same span)
///
/// The lower index is inclusive and the higher index is exclusive.
///
/// An empty span or cursor position is specified with `start == end`.
///
#[derive(Debug, Clone, Copy)]
pub struct TextSpan {
/// The start of the span (inclusive)
pub start: usize,

/// The end of the span (exclusive)
pub end: usize,
}

#[derive(Debug, Clone)]
pub struct TextInputState {
pub text: String,

/// A selection defined on the text.
///
/// To set the cursor position, start and end should have the same value.
///
/// Changing the selection has no effect on the compose_region.
pub selection: TextSpan,

/// A composing region defined on the text.
///
/// When being set, then if there was a composing region, the region is replaced.
///
/// The given indices will be clamped to the `text` bounds
///
/// If the resulting region is zero-sized, no region is marked (equivalent to passing `None`)
pub compose_region: Option<TextSpan>,
}
10 changes: 10 additions & 0 deletions android-activity/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,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
///
/// `callback` should return [`InputStatus::Unhandled`] for any input events that aren't directly
Expand Down
1 change: 1 addition & 0 deletions android-activity/src/native_activity/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,4 +337,5 @@ impl<'a> KeyEvent<'a> {
pub enum InputEvent<'a> {
MotionEvent(self::MotionEvent<'a>),
KeyEvent(self::KeyEvent<'a>),
TextEvent(crate::input::TextInputState),
}
16 changes: 16 additions & 0 deletions android-activity/src/native_activity/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use libc::c_void;
use log::{error, trace};
use ndk::{asset::AssetManager, native_window::NativeWindow};

use crate::input::{TextInputState, TextSpan};
use crate::{
util, AndroidApp, ConfigurationRef, InputStatus, MainEvent, PollEvent, Rect, WindowManagerFlags,
};
Expand Down Expand Up @@ -341,6 +342,20 @@ impl AndroidAppInner {
}
}

// TODO: move into a trait
pub fn text_input_state(&self) -> TextInputState {
TextInputState {
text: String::new(),
selection: TextSpan { start: 0, end: 0 },
compose_region: 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
}
Expand Down Expand Up @@ -390,6 +405,7 @@ impl AndroidAppInner {
input::InputEvent::KeyEvent(e) => {
ndk::event::InputEvent::KeyEvent(e.into_ndk_event())
}
_ => unreachable!(),
};
queue.finish_event(ndk_event, matches!(handled, InputStatus::Handled));
}
Expand Down
Loading