Skip to content

Commit 741e633

Browse files
authored
Merge pull request #24 from rust-mobile/ime-support
Input method (soft keyboard) support
2 parents 96497f9 + 41f30c3 commit 741e633

File tree

8 files changed

+207
-1
lines changed

8 files changed

+207
-1
lines changed

android-activity/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ native-activity = []
2525
[dependencies]
2626
log = "0.4"
2727
jni-sys = "0.3"
28+
cesu8 = "1"
2829
ndk = "0.7"
2930
ndk-sys = "0.4"
3031
ndk-context = "0.1"

android-activity/game-activity-csrc/game-activity/native_app_glue/android_native_app_glue.c

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,7 @@ static void onTextInputEvent(GameActivity* activity,
674674
pthread_mutex_lock(&android_app->mutex);
675675
if (!android_app->destroyed) {
676676
android_app->textInputState = 1;
677+
notifyInput(android_app);
677678
}
678679
pthread_mutex_unlock(&android_app->mutex);
679680
}

android-activity/src/game_activity/input.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ use crate::input::{Class, Source};
2626
pub enum InputEvent<'a> {
2727
MotionEvent(MotionEvent<'a>),
2828
KeyEvent(KeyEvent<'a>),
29+
TextEvent(crate::input::TextInputState),
2930
}
3031

3132
/// A bitfield representing the state of modifier keys during an event.

android-activity/src/game_activity/mod.rs

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ use crate::{
3232
mod ffi;
3333

3434
pub mod input;
35+
use crate::input::{TextInputState, TextSpan};
3536
use input::{Axis, InputEvent, KeyEvent, MotionEvent};
3637

3738
// The only time it's safe to update the android_app->savedState pointer is
@@ -360,6 +361,121 @@ impl AndroidAppInner {
360361
}
361362
}
362363

364+
unsafe extern "C" fn map_input_state_to_text_event_callback(
365+
context: *mut c_void,
366+
state: *const ffi::GameTextInputState,
367+
) {
368+
// Java uses a modified UTF-8 format, which is a modified cesu8 format
369+
let out_ptr: *mut TextInputState = context.cast();
370+
let text_modified_utf8: *const u8 = (*state).text_UTF8.cast();
371+
let text_modified_utf8 =
372+
std::slice::from_raw_parts(text_modified_utf8, (*state).text_length as usize);
373+
match cesu8::from_java_cesu8(&text_modified_utf8) {
374+
Ok(str) => {
375+
let len = *&str.len();
376+
(*out_ptr).text = String::from(str);
377+
378+
let selection_start = (*state).selection.start.clamp(0, len as i32 + 1);
379+
let selection_end = (*state).selection.end.clamp(0, len as i32 + 1);
380+
(*out_ptr).selection = TextSpan {
381+
start: selection_start as usize,
382+
end: selection_end as usize,
383+
};
384+
if (*state).composingRegion.start < 0 || (*state).composingRegion.end < 0 {
385+
(*out_ptr).compose_region = None;
386+
} else {
387+
(*out_ptr).compose_region = Some(TextSpan {
388+
start: (*state).composingRegion.start as usize,
389+
end: (*state).composingRegion.end as usize,
390+
});
391+
}
392+
}
393+
Err(err) => {
394+
log::error!("Invalid UTF8 text in TextEvent: {}", err);
395+
}
396+
}
397+
}
398+
399+
// TODO: move into a trait
400+
pub fn text_input_state(&self) -> TextInputState {
401+
unsafe {
402+
let activity = (*self.native_app.as_ptr()).activity;
403+
let mut out_state = TextInputState {
404+
text: String::new(),
405+
selection: TextSpan { start: 0, end: 0 },
406+
compose_region: None,
407+
};
408+
let out_ptr = &mut out_state as *mut TextInputState;
409+
410+
// NEON WARNING:
411+
//
412+
// It's not clearly documented but the GameActivity API over the
413+
// GameTextInput library directly exposes _modified_ UTF8 text
414+
// from Java so we need to be careful to convert text to and
415+
// from UTF8
416+
//
417+
// GameTextInput also uses a pre-allocated, fixed-sized buffer for the current
418+
// text state but GameTextInput doesn't actually provide it's own thread
419+
// safe API to safely access this state so we have to cooperate with
420+
// the GameActivity code that does locking when reading/writing the state
421+
// (I.e. we can't just punch through to the GameTextInput layer from here).
422+
//
423+
// Overall this is all quite gnarly - and probably a good reminder of why
424+
// we want to use Rust instead of C/C++.
425+
ffi::GameActivity_getTextInputState(
426+
activity,
427+
Some(AndroidAppInner::map_input_state_to_text_event_callback),
428+
out_ptr.cast(),
429+
);
430+
431+
out_state
432+
}
433+
}
434+
435+
// TODO: move into a trait
436+
pub fn set_text_input_state(&self, state: TextInputState) {
437+
unsafe {
438+
let activity = (*self.native_app.as_ptr()).activity;
439+
let modified_utf8 = cesu8::to_java_cesu8(&state.text);
440+
let text_length = modified_utf8.len() as i32;
441+
let modified_utf8_bytes = modified_utf8.as_ptr();
442+
let ffi_state = ffi::GameTextInputState {
443+
text_UTF8: modified_utf8_bytes.cast(), // NB: may be signed or unsigned depending on target
444+
text_length,
445+
selection: ffi::GameTextInputSpan {
446+
start: state.selection.start as i32,
447+
end: state.selection.end as i32,
448+
},
449+
composingRegion: match state.compose_region {
450+
Some(span) => {
451+
// The GameText subclass of InputConnection only has a special case for removing the
452+
// compose region if `start == -1` but the docs for `setComposingRegion` imply that
453+
// the region should effectively be removed if any empty region is given (unlike for the
454+
// selection region, it's not meaningful to maintain an empty compose region)
455+
//
456+
// We aim for more consistent behaviour by normalizing any empty region into `(-1, -1)`
457+
// to remove the compose region.
458+
//
459+
// NB `setComposingRegion` itself is documented to clamp start/end to the text bounds
460+
// so apart from this special-case handling in GameText's implementation of
461+
// `setComposingRegion` then there's nothing special about `(-1, -1)` - it's just an empty
462+
// region that should get clamped to `(0, 0)` and then get removed.
463+
if span.start == span.end {
464+
ffi::GameTextInputSpan { start: -1, end: -1 }
465+
} else {
466+
ffi::GameTextInputSpan {
467+
start: span.start as i32,
468+
end: span.end as i32,
469+
}
470+
}
471+
}
472+
None => ffi::GameTextInputSpan { start: -1, end: -1 },
473+
},
474+
};
475+
ffi::GameActivity_setTextInputState(activity, &ffi_state as *const _);
476+
}
477+
}
478+
363479
pub fn enable_motion_axis(&mut self, axis: Axis) {
364480
unsafe { ffi::GameActivityPointerAxes_enableAxis(axis as i32) }
365481
}
@@ -403,7 +519,7 @@ impl AndroidAppInner {
403519
}
404520
}
405521

406-
pub fn input_events<F>(&self, mut callback: F)
522+
fn dispatch_key_and_motion_events<F>(&self, mut callback: F)
407523
where
408524
F: FnMut(&InputEvent) -> InputStatus,
409525
{
@@ -426,6 +542,28 @@ impl AndroidAppInner {
426542
}
427543
}
428544

545+
fn dispatch_text_events<F>(&self, mut callback: F)
546+
where
547+
F: FnMut(&InputEvent) -> InputStatus,
548+
{
549+
unsafe {
550+
let app_ptr = self.native_app.as_ptr();
551+
if (*app_ptr).textInputState != 0 {
552+
let state = self.text_input_state();
553+
callback(&InputEvent::TextEvent(state));
554+
(*app_ptr).textInputState = 0;
555+
}
556+
}
557+
}
558+
559+
pub fn input_events<F>(&self, mut callback: F)
560+
where
561+
F: FnMut(&InputEvent) -> InputStatus,
562+
{
563+
self.dispatch_key_and_motion_events(&mut callback);
564+
self.dispatch_text_events(&mut callback);
565+
}
566+
429567
pub fn internal_data_path(&self) -> Option<std::path::PathBuf> {
430568
unsafe {
431569
let app_ptr = self.native_app.as_ptr();

android-activity/src/input.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,41 @@ impl From<Source> for Class {
8181
source.into()
8282
}
8383
}
84+
85+
/// This struct holds a span within a region of text from `start` to `end`.
86+
///
87+
/// The `start` index may be greater than the `end` index (swapping `start` and `end` will represent the same span)
88+
///
89+
/// The lower index is inclusive and the higher index is exclusive.
90+
///
91+
/// An empty span or cursor position is specified with `start == end`.
92+
///
93+
#[derive(Debug, Clone, Copy)]
94+
pub struct TextSpan {
95+
/// The start of the span (inclusive)
96+
pub start: usize,
97+
98+
/// The end of the span (exclusive)
99+
pub end: usize,
100+
}
101+
102+
#[derive(Debug, Clone)]
103+
pub struct TextInputState {
104+
pub text: String,
105+
106+
/// A selection defined on the text.
107+
///
108+
/// To set the cursor position, start and end should have the same value.
109+
///
110+
/// Changing the selection has no effect on the compose_region.
111+
pub selection: TextSpan,
112+
113+
/// A composing region defined on the text.
114+
///
115+
/// When being set, then if there was a composing region, the region is replaced.
116+
///
117+
/// The given indices will be clamped to the `text` bounds
118+
///
119+
/// If the resulting region is zero-sized, no region is marked (equivalent to passing `None`)
120+
pub compose_region: Option<TextSpan>,
121+
}

android-activity/src/lib.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,16 @@ impl AndroidApp {
618618
.hide_soft_input(hide_implicit_only);
619619
}
620620

621+
/// Fetch the current input text state, as updated by any active IME.
622+
pub fn text_input_state(&self) -> input::TextInputState {
623+
self.inner.read().unwrap().text_input_state()
624+
}
625+
626+
/// Forward the given input text `state` to any active IME.
627+
pub fn set_text_input_state(&self, state: input::TextInputState) {
628+
self.inner.read().unwrap().set_text_input_state(state);
629+
}
630+
621631
/// Query and process all out-standing input event
622632
///
623633
/// `callback` should return [`InputStatus::Unhandled`] for any input events that aren't directly

android-activity/src/native_activity/input.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,4 +337,5 @@ impl<'a> KeyEvent<'a> {
337337
pub enum InputEvent<'a> {
338338
MotionEvent(self::MotionEvent<'a>),
339339
KeyEvent(self::KeyEvent<'a>),
340+
TextEvent(crate::input::TextInputState),
340341
}

android-activity/src/native_activity/mod.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use libc::c_void;
99
use log::{error, trace};
1010
use ndk::{asset::AssetManager, native_window::NativeWindow};
1111

12+
use crate::input::{TextInputState, TextSpan};
1213
use crate::{
1314
util, AndroidApp, ConfigurationRef, InputStatus, MainEvent, PollEvent, Rect, WindowManagerFlags,
1415
};
@@ -341,6 +342,20 @@ impl AndroidAppInner {
341342
}
342343
}
343344

345+
// TODO: move into a trait
346+
pub fn text_input_state(&self) -> TextInputState {
347+
TextInputState {
348+
text: String::new(),
349+
selection: TextSpan { start: 0, end: 0 },
350+
compose_region: None,
351+
}
352+
}
353+
354+
// TODO: move into a trait
355+
pub fn set_text_input_state(&self, _state: TextInputState) {
356+
// NOP: Unsupported
357+
}
358+
344359
pub fn enable_motion_axis(&self, _axis: input::Axis) {
345360
// NOP - The InputQueue API doesn't let us optimize which axis values are read
346361
}
@@ -390,6 +405,7 @@ impl AndroidAppInner {
390405
input::InputEvent::KeyEvent(e) => {
391406
ndk::event::InputEvent::KeyEvent(e.into_ndk_event())
392407
}
408+
_ => unreachable!(),
393409
};
394410
queue.finish_event(ndk_event, matches!(handled, InputStatus::Handled));
395411
}

0 commit comments

Comments
 (0)