menu-ui 0.1.2

Android egui 内置菜单框架:EGL/GL 渲染、JNI 输入桥接、原生文本编辑框与图片选择
//! `menu-ui`:Android egui UI 框架 crate。
//!
//! 职责:把 `egui` + `egui_glow` 绘制到 Kotlin 创建的 `SurfaceView` 上,
//! 提供 JNI 桥接(挂载 JVM / 注册方法表)、EGL 渲染线程、原生文本编辑框
//! 桥接控件。**不包含任何游戏数据**。
//!
//! # 模块划分
//!
//! | 模块 | 职责 |
//! |------|------|
//! | [`state`]    | 渲染线程基础设施共享状态(触摸队列、文本桥接、egui 上下文等) |
//! | [`renderer`] | 渲染线程:EGL 初始化、egui_glow 后端、逐帧输入/布局/绘制循环、字体加载 |
//! | [`text`]     | “点击弹出 Android 原生编辑框”的文本输入控件(框架层) |
//! | [`kotlin`]   | 渲染线程向 Kotlin 的回调(弹出原生文本编辑框) |
//! | [`native`]   | JNI 入口:五个 native 方法 + 方法表 |
//! | 本文件       | [`init`]:挂载 JVM 并注册方法表(含 ROOT_PATH 优先初始化);
//!               [`set_ui_renderer`]:注册实际 UI(由宿主 crate 传入) |
//!
//! # 使用方法(宿主 crate,如 `menu`)
//!
//! 宿主 crate 在 `JNI_OnLoad` 中调用:
//! ```rust,ignore
//! menu_ui::init(vm, ui::build_ui);   // 挂载 JVM + 注册方法表 + 设置实际 UI
//! ```
//! 实际 UI 是 `fn(&mut egui::Ui)`;UI 状态由宿主 crate 自持(全局变量),
//! `menu-ui` 不感知其类型(避免宿主 <-> 框架循环依赖)。

mod kotlin;
mod native;
mod renderer;
mod state;
mod text;

/// 重导出 `egui`:使用方写 UI 时统一走 `menu_ui::egui::...`,
/// 无需自己声明 egui 依赖(版本由本 crate 锁定,避免双重编译/不匹配)。
pub use egui;
/// 重导出 `jni`:`JNI_OnLoad(vm: *mut jni::sys::JavaVM)` 等签名所需类型
/// 统一走 `menu_ui::jni::...`,版本由本 crate 锁定。
pub use jni;

pub use crate::text::{
    parse_unity_rich_text, unity_rich_text, NativeNumber, NativeNumberInput, NativeTextEdit,
    TextInputMode, UnityTextSegment,
};

use jni::signature::RuntimeMethodSignature;
use jni::strings::{JNIStr, JNIString};
use std::sync::Mutex;

use jni::errors::Result as JniResult;
use jni::objects::{JObject, JString};
use jni::JavaVM;

use crate::native::build_native_methods;

/// Java 包名(Kotlin 桥接类所在包,与 `android/app/build.gradle.kts` 的
/// `namespace` / 源码目录 `java/com/wuyan/egui/` 保持一致)。
fn pkg() -> &'static str {
    "com.wuyan.egui"
}

/// 由包名派生 Kotlin 桥接类的 JNI 内部类名(点号 -> 斜杠,如 `com/wuyan/egui/EguiBridge`)。
fn bridge_class_name() -> String {
    format!("{}/{}", pkg(), "EguiBridge").replace('.', "/")
}

/// 把 `&'static str` 转成 JNI 需要的 `&'static JNIStr`。
///
/// 方法名 / 签名都是 ASCII,UTF-8 与 JNI 的 Modified UTF-8 完全一致;
/// `CString` 保证以 NUL 结尾,`Box::leak` 泄漏为进程级静态
/// (进程初始化阶段执行,泄漏量可忽略)。
pub(crate) fn jni_static(s: &'static str) -> &'static JNIStr {
    let c = std::ffi::CString::new(s).expect("JNI string contains NUL");
    let leaked: &'static std::ffi::CStr = Box::leak(c.into_boxed_c_str());
    // Safety: CString 保证 NUL 结尾且无内嵌 NUL;ASCII 的 MUTF-8 编码与 UTF-8 相同。
    unsafe { JNIStr::from_cstr_unchecked(leaked) }
}

// ---------------------------------------------------------------------------
// 实际 UI 渲染回调:init 时由宿主 crate 传入,未设置则渲染线程不渲染 egui
// ---------------------------------------------------------------------------

/// 实际 UI 渲染回调的类型(闭包)。
///
/// `menu-ui` 不感知 UI 状态类型:UI 状态由宿主 crate 自持,闭包通过捕获
/// 持有(如 `&'static mut UIState`)。未设置时渲染线程不运行 egui UI
/// (Surface 保持全透明,不绘制任何窗口/控件)。
pub type UiRenderFn = Box<dyn FnMut(&mut egui::Ui) + Send + 'static>;

/// 进程级 UI 渲染回调;[`init`] / [`set_ui_renderer`] 中设置。
/// 渲染线程每帧持锁调用(调用期间其它线程设置会短暂等待)。
pub(crate) static UI_RENDERER: Mutex<Option<UiRenderFn>> = Mutex::new(None);

/// 设置实际 UI 渲染回调(如宿主 crate 的 `ui::build_ui` 包装闭包)。
///
/// 通常无需直接调用([`init`] 会携带一起设置);也可以在任何线程随时替换。
/// 不设置则渲染线程不渲染 egui。
pub fn set_ui_renderer(render_fn: UiRenderFn) {
    *UI_RENDERER.lock().unwrap() = Some(render_fn);
}

// ---------------------------------------------------------------------------
// 框架初始化:ROOT_PATH 优先,然后挂载 JVM 并注册方法表
// ---------------------------------------------------------------------------

/// 宿主 crate `JNI_OnLoad` 中调用的框架初始化入口。
///
/// 按顺序完成:
/// 1. **ROOT_PATH 优先初始化**(best-effort,失败只记 warning,不阻断);
/// 2. 挂载到 JVM 并通过 `RegisterNatives` 注册方法表(硬要求);
/// 3. 设置实际 UI 渲染函数(`build_ui` 由宿主传入)。
///
/// `vm` 为 `JNI_OnLoad` 收到的原始 `JavaVM*`。
pub fn init(vm: *mut jni::sys::JavaVM, build_ui: UiRenderFn) {
    // 挂载 JVM(一次 attach 内:先 ROOT_PATH,再注册方法表)。
    let result = unsafe { JavaVM::from_raw(vm) }.attach_current_thread(|env| -> JniResult<()> {
        // 1. ROOT_PATH 优先初始化(best-effort,失败不影响注册)。
        match fetch_external_files_dir(env) {
            Ok(path) => {
                *ROOT_PATH.lock().unwrap() = path;
            }
            Err(e) => log::warn!("获取外部存储目录失败(不影响启动) {e}"),
        }
        // 2. 注册 native 方法表。
        let class = env.find_class(jni::strings::JNIString::from(bridge_class_name()))?;
        let methods = build_native_methods();
        unsafe { env.register_native_methods(&class, &methods)? };
        Ok(())
    });
    match result {
        Ok(()) => {}
        Err(e) => log::error!("menu-ui init failed: {e}"),
    }
    // 3. 设置实际 UI。
    set_ui_renderer(build_ui);
}

static ROOT_PATH: Mutex<String> = Mutex::new(String::new());

/// 应用外部存储目录(进程级,`init` 中 best-effort 获取)。
///
/// 任何线程/模块可直接调用;获取失败(部分系统版本 `ActivityThread`/
/// `getApplication` 为 @hide API 可能不可用)时返回空字符串。
pub fn root_path() -> String {
    ROOT_PATH.lock().unwrap().clone()
}

// ---------------------------------------------------------------------------
// 选图(系统文件选择器)公开 API:宿主 UI 调用,结果在下一帧取回
// ---------------------------------------------------------------------------

/// 请求打开 Android 系统文件选择器(`ACTION_OPEN_DOCUMENT`),并把用户
/// 选中的图片**拷贝**到 `dest_path`(绝对路径,父目录自动创建)。
///
/// 调用点:宿主 UI 渲染回调(`build_ui` 内,运行在渲染线程)。返回 `true`
/// 表示请求已发出;用户取消或拷贝失败时结果同样会回写(值为 `None`)。
///
/// 结果由 [`take_image_pick_result`] 在后续帧取回(同一 `request_id`)。
pub fn request_image_pick(request_id: u64, dest_path: &str, title: &str) -> bool {
    match crate::state::try_app_state() {
        Some(state) => crate::kotlin::request_image_picker(&state, request_id, dest_path, title),
        None => {
            log::error!("request_image_pick: 渲染会话尚未初始化(nativeAttach 未调用)");
            false
        }
    }
}

/// 取回一次选图的结果(非阻塞):
/// - `None`:结果尚未到达(继续在下一帧轮询);
/// - `Some(None)`:用户取消 / 拷贝失败;
/// - `Some(Some(path))`:已拷贝到本地的图片绝对路径。
pub fn take_image_pick_result(request_id: u64) -> Option<Option<String>> {
    let mut picker = crate::state::IMAGE_PICKER.lock().unwrap();
    if let Some(pos) = picker
        .results
        .iter()
        .position(|(id, _)| *id == request_id)
    {
        return picker.results.remove(pos).map(|(_, path)| path);
    }
    None
}

/// 系统文件选择器当前是否打开(打开期间不再发起新请求)。
pub fn image_picker_open() -> bool {
    crate::state::IMAGE_PICKER.lock().unwrap().open
}

/// 竖向滚动区(触摸可拖内容滚动,带惯性;`salt` 隔离各滚动区的位置记忆)。
///
/// 两件事都在本函数处理:
/// - egui 0.36 的 `ScrollArea` 默认 `drag = DragScroll::OnTouch`,而本项目
///   输入桥接(`renderer.rs`)把 `MotionEvent` 翻译成纯指针事件、从不发
///   `Event::Touch`,`has_touch_screen()` 恒 false,内容拖动被禁用——只能拖
///   滚动条。这里强制 `DragScroll::Always`,触摸拖动 / 滚动条 / 滚轮全可用。
/// - 滚动位置的持久化 Id = `父Ui.id + id_salt`,而默认 salt 是固定值
///   `"scroll_area"`——同一父 Ui 下的所有滚动区会**共享同一个滚动位置**
///   (如主面板的各标签页列表)。必须给每个调用点传不同的 `salt`(如
///   `"列表A"`、`"列表B"`)。
///
/// # 示例
/// ```ignore
/// menu_ui::scroll_vertical("列表A")
///     .max_height(400.0)
///     .show(ui, |ui| { ... });
/// ```
// pub fn scroll_vertical(salt: impl egui::AsIdSalt) -> egui::ScrollArea {
//     egui::ScrollArea::vertical()
//         .id_salt(salt)
//         .scroll_source(egui::containers::scroll_area::ScrollSource {
//             drag: egui::containers::scroll_area::DragScroll::Always,
//             ..Default::default()
//         })
// }

/// 从 `ActivityThread` 的应用上下文获取外部存储目录(best-effort)。
///
/// 失败只影响 [`root_path`],不影响 native 方法注册。
fn fetch_external_files_dir(env: &mut jni::Env) -> JniResult<String> {
    let activity_thread_class =
        env.find_class(jni::strings::JNIString::from("android/app/ActivityThread"))?;
    let activity_thread = env.call_static_method(
        activity_thread_class,
        JNIString::from("currentActivityThread"),
        RuntimeMethodSignature::from_str("()Landroid/app/ActivityThread;")?.method_signature(),
        &[],
    )?
    .into_object()?;
    let application = env.call_method(
        activity_thread,
        JNIString::from("getApplication"),
        RuntimeMethodSignature::from_str("()Landroid/app/Application;")?.method_signature(),
        &[],
    )?
    .into_object()?;
    if application.is_null() {
        return Err(jni::errors::Error::NullPtr("Application is null"));
    }
    let file = env.call_method(
        application,
        JNIString::from("getExternalFilesDir"),
        RuntimeMethodSignature::from_str("(Ljava/lang/String;)Ljava/io/File;")?.method_signature(),
        &[(&JObject::null()).into()],
    )?
    .into_object()?;
    if file.is_null() {
        return Err(jni::errors::Error::NullPtr("getExternalFilesDir returned null"));
    }
    let path = env.call_method(
        file,
        JNIString::from("getAbsolutePath"),
        RuntimeMethodSignature::from_str("()Ljava/lang/String;")?.method_signature(),
        &[],
    )?
    .into_object()?;
    Ok(JString::cast_local(env, path)?.to_string())
}