slint/internal/backends/android-activity/javahelper.rs
David Faure b23a657c44 Hide Android selection handles when scrolled out of view
In a LineEdit with a long text, when dragging the selection
to the point where the anchor scrolls out of view in the other
direection, it used to keep being visible, outside the LineEdit.

This commit fixes that by passing the clip rect to the java code
that positions the selection handles.
2025-09-16 08:50:29 +02:00

533 lines
21 KiB
Rust

// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
use super::*;
use i_slint_core::api::{PhysicalPosition, PhysicalSize};
use i_slint_core::graphics::{euclid, Color};
use i_slint_core::items::{ColorScheme, InputType};
use i_slint_core::platform::WindowAdapter;
use i_slint_core::SharedString;
use jni::objects::{JClass, JObject, JString, JValue};
use jni::sys::{jboolean, jint};
use jni::JNIEnv;
use std::time::Duration;
#[track_caller]
pub fn print_jni_error(app: &AndroidApp, e: jni::errors::Error) -> ! {
let vm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr() as *mut _) }.unwrap();
let env = vm.attach_current_thread().unwrap();
let _ = env.exception_describe();
panic!("JNI error: {e:?}")
}
pub struct JavaHelper(jni::objects::GlobalRef, AndroidApp);
fn load_java_helper(app: &AndroidApp) -> Result<jni::objects::GlobalRef, jni::errors::Error> {
// Safety: as documented in android-activity to obtain a jni::JavaVM
let vm = unsafe { jni::JavaVM::from_raw(app.vm_as_ptr() as *mut _) }?;
let native_activity = unsafe { JObject::from_raw(app.activity_as_ptr() as *mut _) };
let mut env = vm.attach_current_thread()?;
let dex_data = include_bytes!(concat!(env!("OUT_DIR"), "/classes.dex"));
// Safety: dex_data is 'static and the InMemoryDexClassLoader will not mutate it it
let dex_buffer =
unsafe { env.new_direct_byte_buffer(dex_data.as_ptr() as *mut _, dex_data.len()).unwrap() };
let parent_class_loader = env
.call_method(&native_activity, "getClassLoader", "()Ljava/lang/ClassLoader;", &[])?
.l()?;
let os_build_class = env.find_class("android/os/Build$VERSION")?;
let sdk_ver = env.get_static_field(os_build_class, "SDK_INT", "I")?.i()?;
let dex_loader = if sdk_ver >= 26 {
env.new_object(
"dalvik/system/InMemoryDexClassLoader",
"(Ljava/nio/ByteBuffer;Ljava/lang/ClassLoader;)V",
&[JValue::Object(&dex_buffer), JValue::Object(&parent_class_loader)],
)?
} else {
let code_cache_path = {
let dir = env
.call_method(&native_activity, "getCodeCacheDir", "()Ljava/io/File;", &[])?
.l()?;
let path =
env.call_method(&dir, "getAbsolutePath", "()Ljava/lang/String;", &[])?.l()?;
jni_get_string(&path, &mut env)
.map(|s| s.to_string_lossy().into_owned())
.map(std::path::PathBuf::from)?
};
let dex_name = env!("CARGO_CRATE_NAME").to_string() + ".dex";
let dex_file_path = code_cache_path.join(dex_name);
std::fs::write(&dex_file_path, dex_data).unwrap(); // Note: this panics on failure
let dex_file_path = env.new_string(&dex_file_path.as_os_str().to_string_lossy())?;
let oats_dir_path = code_cache_path.join("oats");
let _ = std::fs::create_dir(&oats_dir_path);
let oats_dir_path = env.new_string(&oats_dir_path.as_os_str().to_string_lossy())?;
env.new_object(
"dalvik/system/DexClassLoader",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/ClassLoader;)V",
&[
(&dex_file_path).into(),
(&oats_dir_path).into(),
(&JObject::null()).into(),
(&parent_class_loader).into(),
],
)?
};
let class_name = env.new_string("SlintAndroidJavaHelper")?;
let helper_class = env
.call_method(
dex_loader,
"findClass",
"(Ljava/lang/String;)Ljava/lang/Class;",
&[JValue::Object(&class_name)],
)?
.l()?;
let helper_class: JClass = helper_class.into();
let methods = [
jni::NativeMethod {
name: "updateText".into(),
sig: "(Ljava/lang/String;IIII)V".into(),
fn_ptr: Java_SlintAndroidJavaHelper_updateText as *mut _,
},
jni::NativeMethod {
name: "setNightMode".into(),
sig: "(I)V".into(),
fn_ptr: Java_SlintAndroidJavaHelper_setNightMode as *mut _,
},
jni::NativeMethod {
name: "moveCursorHandle".into(),
sig: "(III)V".into(),
fn_ptr: Java_SlintAndroidJavaHelper_moveCursorHandle as *mut _,
},
jni::NativeMethod {
name: "popupMenuAction".into(),
sig: "(I)V".into(),
fn_ptr: Java_SlintAndroidJavaHelper_popupMenuAction as *mut _,
},
];
env.register_native_methods(&helper_class, &methods)?;
let helper_instance = env.new_object(
helper_class,
"(Landroid/app/Activity;)V",
&[JValue::Object(&native_activity)],
)?;
Ok(env.new_global_ref(&helper_instance)?)
}
impl JavaHelper {
pub fn new(app: &AndroidApp) -> Result<Self, jni::errors::Error> {
Ok(Self(load_java_helper(app)?, app.clone()))
}
fn with_jni_env<R>(
&self,
f: impl FnOnce(&mut JNIEnv, &JObject<'static>) -> Result<R, jni::errors::Error>,
) -> Result<R, jni::errors::Error> {
// Safety: as documented in android-activity to obtain a jni::JavaVM
let vm = unsafe { jni::JavaVM::from_raw(self.1.vm_as_ptr() as *mut _) }?;
let mut env = vm.attach_current_thread()?;
let helper = self.0.as_obj();
f(&mut env, helper)
}
/// Unfortunately, the way that the android-activity crate uses to show or hide the virtual keyboard doesn't
/// work with native-activity. So do it manually with JNI
pub fn show_or_hide_soft_input(&self, show: bool) -> Result<(), jni::errors::Error> {
self.with_jni_env(|env, helper| {
if show {
env.call_method(helper, "show_keyboard", "()V", &[])?;
} else {
env.call_method(helper, "hide_keyboard", "()V", &[])?;
};
Ok(())
})
}
pub fn set_imm_data(
&self,
data: &i_slint_core::window::InputMethodProperties,
scale_factor: f32,
show_cursor_handles: bool,
) -> Result<(), jni::errors::Error> {
self.with_jni_env(|env, helper| {
let mut text = data.text.to_string();
let mut cursor_position = data.cursor_position;
let mut anchor_position = data.anchor_position.unwrap_or(data.cursor_position);
if !data.preedit_text.is_empty() {
text.insert_str(data.preedit_offset, data.preedit_text.as_str());
if cursor_position >= data.preedit_offset {
cursor_position += data.preedit_text.len()
}
if anchor_position >= data.preedit_offset {
anchor_position += data.preedit_text.len()
}
}
let to_utf16 = |x| convert_utf8_index_to_utf16(&text, x as usize);
let text = &env.auto_local(env.new_string(text.as_str())?);
let class_it = env.find_class("android/text/InputType")?;
let input_type = match data.input_type {
InputType::Text => env.get_static_field(&class_it, "TYPE_CLASS_TEXT", "I")?.i()?,
InputType::Password => {
env.get_static_field(&class_it, "TYPE_TEXT_VARIATION_PASSWORD", "I")?.i()?
| env.get_static_field(&class_it, "TYPE_CLASS_TEXT", "I")?.i()?
}
InputType::Number => {
env.get_static_field(&class_it, "TYPE_CLASS_NUMBER", "I")?.i()?
}
InputType::Decimal => {
env.get_static_field(&class_it, "TYPE_CLASS_NUMBER", "I")?.i()?
| env.get_static_field(&class_it, "TYPE_NUMBER_FLAG_DECIMAL", "I")?.i()?
}
_ => 0 as jint,
};
env.delete_local_ref(class_it)?;
let cur_origin = data.cursor_rect_origin.to_physical(scale_factor); // i32
let anchor_origin = data.anchor_point.to_physical(scale_factor);
let cur_size = data.cursor_rect_size.to_physical(scale_factor);
let cur_visible = data.clip_rect.map_or(true, |r| {
r.contains(i_slint_core::lengths::logical_point_from_api(data.cursor_rect_origin))
});
let anchor_visible = data.clip_rect.map_or(true, |r| {
r.contains(i_slint_core::lengths::logical_point_from_api(data.anchor_point))
});
// Add 2*cur_size.width to the y position to be a bit under the cursor
let cursor_height = cur_size.height as i32 + 2 * cur_size.width as i32;
let cur_x = if cur_visible { cur_origin.x + cur_size.width as i32 / 2 } else { -1 };
let cur_y = cur_origin.y + cursor_height;
let anchor_x = if anchor_visible { anchor_origin.x } else { -1 };
let anchor_y = anchor_origin.y + 2 * cur_size.width as i32;
env.call_method(
helper,
"set_imm_data",
"(Ljava/lang/String;IIIIIIIIIIZ)V",
&[
JValue::Object(&text),
JValue::from(to_utf16(cursor_position) as jint),
JValue::from(to_utf16(anchor_position) as jint),
JValue::from(to_utf16(data.preedit_offset) as jint),
JValue::from(to_utf16(data.preedit_offset + data.preedit_text.len()) as jint),
JValue::from(cur_x as jint),
JValue::from(cur_y as jint),
JValue::from(anchor_x as jint),
JValue::from(anchor_y as jint),
JValue::from(cursor_height as jint),
JValue::from(input_type),
JValue::from(show_cursor_handles as jboolean),
],
)?;
Ok(())
})
}
pub fn color_scheme(&self) -> Result<i32, jni::errors::Error> {
self.with_jni_env(|env, helper| {
Ok(env.call_method(helper, "color_scheme", "()I", &[])?.i()?)
})
}
pub fn get_view_rect(&self) -> Result<(PhysicalPosition, PhysicalSize), jni::errors::Error> {
self.with_jni_env(|env, helper| {
let rect =
env.call_method(helper, "get_view_rect", "()Landroid/graphics/Rect;", &[])?.l()?;
let rect = env.auto_local(rect);
let x = env.get_field(&rect, "left", "I")?.i()?;
let y = env.get_field(&rect, "top", "I")?.i()?;
let width = env.get_field(&rect, "right", "I")?.i()? - x;
let height = env.get_field(&rect, "bottom", "I")?.i()? - y;
Ok((PhysicalPosition::new(x as _, y as _), PhysicalSize::new(width as _, height as _)))
})
}
pub fn set_handle_color(&self, color: Color) -> Result<(), jni::errors::Error> {
self.with_jni_env(|env, helper| {
env.call_method(
helper,
"set_handle_color",
"(I)V",
&[JValue::from(color.as_argb_encoded() as jint)],
)?;
Ok(())
})
}
pub fn long_press_timeout(&self) -> Result<Duration, jni::errors::Error> {
self.with_jni_env(|env, _helper| {
let long_press_timeout = env
.call_static_method(
"android/view/ViewConfiguration",
"getLongPressTimeout",
"()I",
&[],
)?
.i()?;
Ok(Duration::from_millis(long_press_timeout as _))
})
}
pub fn show_action_menu(&self) -> Result<(), jni::errors::Error> {
self.with_jni_env(|env, helper| {
env.call_method(helper, "show_action_menu", "()V", &[])?;
Ok(())
})
}
pub fn set_clipboard(&self, text: &str) -> Result<(), jni::errors::Error> {
self.with_jni_env(|env, helper| {
let text = env.auto_local(env.new_string(text)?);
env.call_method(
helper,
"set_clipboard",
"(Ljava/lang/String;)V",
&[JValue::Object(&text)],
)?;
Ok(())
})
}
pub fn get_clipboard(&self) -> Result<String, jni::errors::Error> {
self.with_jni_env(|env, helper| {
let j_string = env
.call_method(helper, "get_clipboard", "()Ljava/lang/String;", &[])?
.l()
.map(|l| env.auto_local(l))?;
let string = jni_get_string(j_string.as_ref(), env)?.into();
Ok(string)
})
}
}
#[unsafe(no_mangle)]
extern "system" fn Java_SlintAndroidJavaHelper_updateText(
mut env: JNIEnv,
_class: JClass,
text: JString,
cursor_position: jint,
anchor_position: jint,
preedit_start: jint,
preedit_end: jint,
) {
let Ok(java_str) = jni_get_string(&text, &mut env) else { return };
let decoded: std::borrow::Cow<str> = (&java_str).into();
let text = SharedString::from(decoded.as_ref());
let cursor_position = convert_utf16_index_to_utf8(&text, cursor_position as usize);
let anchor_position = convert_utf16_index_to_utf8(&text, anchor_position as usize);
let preedit_start = convert_utf16_index_to_utf8(&text, preedit_start as usize);
let preedit_end = convert_utf16_index_to_utf8(&text, preedit_end as usize);
i_slint_core::api::invoke_from_event_loop(move || {
if let Some(adaptor) = CURRENT_WINDOW.with_borrow(|x| x.upgrade()) {
adaptor.show_cursor_handles.set(false);
let runtime_window = i_slint_core::window::WindowInner::from_pub(&adaptor.window);
let event = if preedit_start != preedit_end {
let adjust = |pos| {
if pos <= preedit_start {
pos
} else if pos >= preedit_end {
preedit_start + (pos - preedit_end)
} else {
preedit_start
}
} as i32;
i_slint_core::input::KeyEvent {
event_type: i_slint_core::input::KeyEventType::UpdateComposition,
text: i_slint_core::format!(
"{}{}",
&text[..preedit_start],
&text[preedit_end..]
),
preedit_text: text[preedit_start..preedit_end].into(),
preedit_selection: Some(0..(preedit_end - preedit_start) as i32),
replacement_range: Some(i32::MIN..i32::MAX),
cursor_position: Some(adjust(cursor_position)),
anchor_position: Some(adjust(anchor_position)),
..Default::default()
}
} else {
i_slint_core::input::KeyEvent {
event_type: i_slint_core::input::KeyEventType::CommitComposition,
text,
replacement_range: Some(i32::MIN..i32::MAX),
cursor_position: Some(cursor_position as _),
anchor_position: Some(anchor_position as _),
..Default::default()
}
};
runtime_window.process_key_input(event);
}
})
.unwrap()
}
fn convert_utf16_index_to_utf8(in_str: &str, utf16_index: usize) -> usize {
let mut utf16_counter = 0;
for (utf8_index, c) in in_str.char_indices() {
if utf16_counter >= utf16_index {
return utf8_index;
}
utf16_counter += c.len_utf16();
}
in_str.len()
}
fn convert_utf8_index_to_utf16(in_str: &str, utf8_index: usize) -> usize {
in_str[..utf8_index].encode_utf16().count()
}
#[unsafe(no_mangle)]
extern "system" fn Java_SlintAndroidJavaHelper_setNightMode(
_env: JNIEnv,
_class: JClass,
night_mode: jint,
) {
i_slint_core::api::invoke_from_event_loop(move || {
if let Some(w) = CURRENT_WINDOW.with_borrow(|x| x.upgrade()) {
w.color_scheme.as_ref().set(match night_mode {
0x10 => ColorScheme::Light, // UI_MODE_NIGHT_NO(0x10)
0x20 => ColorScheme::Dark, // UI_MODE_NIGHT_YES(0x20)
0x0 => ColorScheme::Unknown, // UI_MODE_NIGHT_UNDEFINED
_ => ColorScheme::Unknown,
});
}
})
.unwrap()
}
#[unsafe(no_mangle)]
extern "system" fn Java_SlintAndroidJavaHelper_moveCursorHandle(
_env: JNIEnv,
_class: JClass,
id: jint,
pos_x: jint,
pos_y: jint,
) {
i_slint_core::api::invoke_from_event_loop(move || {
if let Some(adaptor) = CURRENT_WINDOW.with_borrow(|x| x.upgrade()) {
if let Some(focus_item) = i_slint_core::window::WindowInner::from_pub(&adaptor.window)
.focus_item
.borrow()
.upgrade()
{
if let Some(text_input) = focus_item.downcast::<i_slint_core::items::TextInput>() {
let scale_factor = adaptor.window.scale_factor();
let adaptor = adaptor.clone() as Rc<dyn WindowAdapter>;
let size = text_input
.as_pin_ref()
.font_request(&focus_item)
.pixel_size
.unwrap_or_default()
.get();
let pos =
euclid::point2(
pos_x as f32 / scale_factor,
pos_y as f32 / scale_factor - size / 2.,
) - focus_item.map_to_window(focus_item.geometry().origin).to_vector();
let text_pos = text_input.as_pin_ref().byte_offset_for_position(
pos,
&adaptor,
&focus_item,
);
let cur_pos = if id == 0 {
text_input.anchor_position_byte_offset.set(text_pos as i32);
text_pos as i32
} else {
let current_cursor = text_input.as_pin_ref().cursor_position_byte_offset();
let current_anchor = text_input.as_pin_ref().anchor_position_byte_offset();
if (id == 1 && current_anchor < current_cursor)
|| (id == 2 && current_anchor > current_cursor)
{
if current_cursor == text_pos as i32 {
return;
}
text_input.anchor_position_byte_offset.set(text_pos as i32);
current_cursor
} else {
if current_anchor == text_pos as i32 {
return;
}
text_pos as i32
}
};
text_input.as_pin_ref().set_cursor_position(
cur_pos,
true,
i_slint_core::items::TextChangeNotify::TriggerCallbacks,
&adaptor,
&focus_item,
);
}
}
}
})
.unwrap()
}
#[unsafe(no_mangle)]
extern "system" fn Java_SlintAndroidJavaHelper_popupMenuAction(
_env: JNIEnv,
_class: JClass,
id: jint,
) {
i_slint_core::api::invoke_from_event_loop(move || {
if let Some(adaptor) = CURRENT_WINDOW.with_borrow(|x| x.upgrade()) {
if let Some(focus_item) = i_slint_core::window::WindowInner::from_pub(&adaptor.window)
.focus_item
.borrow()
.upgrade()
{
if let Some(text_input) = focus_item.downcast::<i_slint_core::items::TextInput>() {
let text_input = text_input.as_pin_ref();
let adaptor = adaptor.clone() as Rc<dyn WindowAdapter>;
match id {
0 => text_input.cut(&adaptor, &focus_item),
1 => text_input.copy(&adaptor, &focus_item),
2 => text_input.paste(&adaptor, &focus_item),
3 => text_input.select_all(&adaptor, &focus_item),
_ => (),
}
}
}
}
})
.unwrap()
}
/// Workaround before <https://github.com/jni-rs/jni-rs/pull/557> is merged.
fn jni_get_string<'e, 'a>(
obj: &'a JObject<'a>,
env: &mut JNIEnv<'e>,
) -> Result<jni::strings::JavaStr<'e, 'a, 'a>, jni::errors::Error> {
use jni::errors::{Error::*, JniError};
let string_class = env.find_class("java/lang/String")?;
let string_class = env.auto_local(string_class);
let obj_class = env.get_object_class(obj)?;
let obj_class = env.auto_local(obj_class);
if !env.is_assignable_from(string_class, obj_class)? {
return Err(JniCall(JniError::InvalidArguments));
}
let j_string: &jni::objects::JString<'_> = obj.into();
// SAFETY: We check that the passed in Object is actually a java.lang.String
unsafe { env.get_string_unchecked(j_string) }
}