diff --git a/iris/Cargo.lock b/iris/Cargo.lock index e0239f3..a61b344 100644 --- a/iris/Cargo.lock +++ b/iris/Cargo.lock @@ -88,6 +88,20 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" +[[package]] +name = "android-view" +version = "0.1.0" +source = "git+https://github.com/rust-mobile/android-view.git?rev=bec6c62a96cef8239b0fd7fedeef9b184d02e3a1#bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" +dependencies = [ + "dpi", + "jni", + "ndk", + "num_enum", + "send_wrapper", + "smallvec", + "ui-events", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -739,15 +753,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "font-types" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" -dependencies = [ - "bytemuck", -] - [[package]] name = "font-types" version = "0.12.4" @@ -771,7 +776,7 @@ dependencies = [ "objc2-core-text", "objc2-foundation 0.3.2", "parlance", - "read-fonts 0.41.0", + "read-fonts", "roxmltree", "smallvec", "windows", @@ -933,7 +938,7 @@ checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" dependencies = [ "bitflags 2.10.0", "bytemuck", - "read-fonts 0.41.0", + "read-fonts", "smallvec", ] @@ -1171,12 +1176,14 @@ dependencies = [ name = "iris" version = "0.1.0" dependencies = [ + "android-view", "arboard", "image", "iris-core", "iris-macro", "parley", "pollster", + "send_wrapper", "swash", "tokio", "wgpu", @@ -1255,6 +1262,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.10.0", + "serde", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -1971,7 +1988,7 @@ dependencies = [ "linebender_resource_handle", "parlance", "parley_data", - "skrifa 0.44.0", + "skrifa", ] [[package]] @@ -2314,16 +2331,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "read-fonts" -version = "0.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eaa2941a4c05443ee3a7b26ab076a553c343ad5995230cc2b1d3e993bdc6345" -dependencies = [ - "bytemuck", - "font-types 0.10.1", -] - [[package]] name = "read-fonts" version = "0.41.0" @@ -2331,7 +2338,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.4", + "font-types", "once_cell", ] @@ -2455,6 +2462,12 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -2506,16 +2519,6 @@ dependencies = [ "quote", ] -[[package]] -name = "skrifa" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9eb0b904a04d09bd68c65d946617b8ff733009999050f3b851c32fb3cfb60e" -dependencies = [ - "bytemuck", - "read-fonts 0.36.0", -] - [[package]] name = "skrifa" version = "0.44.0" @@ -2523,7 +2526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" dependencies = [ "bytemuck", - "read-fonts 0.41.0", + "read-fonts", ] [[package]] @@ -2614,7 +2617,7 @@ version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" dependencies = [ - "skrifa 0.39.0", + "skrifa", "yazi", "zeno", ] @@ -2823,6 +2826,16 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "ui-events" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4c2cc34489c685d4e7a1a1f97b7b4416c5aa789892114ae77df6cd2a60f0ec4" +dependencies = [ + "dpi", + "keyboard-types", +] + [[package]] name = "unicode-ident" version = "1.0.22" diff --git a/iris/Cargo.toml b/iris/Cargo.toml index 045fd80..683c4fd 100644 --- a/iris/Cargo.toml +++ b/iris/Cargo.toml @@ -10,13 +10,36 @@ iris-core = { workspace = true } iris-macro = { workspace = true } parley = { workspace = true } swash = { workspace = true } -winit = { workspace = true } -arboard = { workspace = true, features = ["wayland-data-control"] } pollster = { workspace = true } wgpu = { workspace = true } image = { workspace = true } tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] } +# winit everywhere except Android; android-view (below) is what stands in +# for it there. Both backends live in this crate (see `src/android/mod.rs`'s +# doc comment) but are never compiled together: winit's own Android support +# pulls in `android-activity`, which panics at compile time unless one of +# its own backend features is picked, and picking one is exactly what +# `iris-core` was kept free of (RUST.md's I0b). Confirmed by trying it +# 2026-09-05: `cargo ndk -t x86_64 -P 26 build -p iris` failed inside +# `android-activity` itself with "Either game-activity or native-activity +# must be enabled" before this split existed. +[target.'cfg(not(target_os = "android"))'.dependencies] +winit = { workspace = true } +arboard = { workspace = true, features = ["wayland-data-control"] } + +# Pinned to the exact commit RUST.md's E1 (2026-09-04) measured on this +# emulator -- real Vulkan rendering, a working `InputConnection`, and the +# accesskit-detach abort, all against this rev specifically. Advancing it +# wants re-running E1's checks, the same reason the nightly toolchain pin +# is dated rather than floating. +[target.'cfg(target_os = "android")'.dependencies] +android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" } +# Not re-exported by android-view (only `jni` and `ndk` are), and needed +# for `android/insets.rs`'s own id -> state map -- the same reason +# android-view's own `PEER_MAP` carries one. +send_wrapper = "0.6.0" + [dev-dependencies] tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] } diff --git a/iris/src/android/attr.rs b/iris/src/android/attr.rs new file mode 100644 index 0000000..dbb163f --- /dev/null +++ b/iris/src/android/attr.rs @@ -0,0 +1,25 @@ +use crate::attr::{FocusHost, recent_click}; +use crate::prelude::*; + +use super::view::HasAndroidUiState; + +impl FocusHost for T { + fn recent_click(&mut self) -> bool { + recent_click(&mut self.android_state_mut().last_click) + } + + fn set_focus(&mut self, id: Option>) { + self.android_state_mut().focus = id; + } + + fn focus_gained(&mut self, region: Option) { + // Showing the keyboard is a JNI call (`InputMethodManager.showSoftInput`), + // and this runs deep inside the platform-agnostic sensor dispatch + // with no `CallbackCtx` in reach -- `IrisViewPeer::after_input` + // (`view.rs`) is what actually makes the call, right after the + // sensor pass that got here returns. + if region.is_some() { + self.android_state_mut().pending_show_keyboard = true; + } + } +} diff --git a/iris/src/android/ime.rs b/iris/src/android/ime.rs new file mode 100644 index 0000000..07ac959 --- /dev/null +++ b/iris/src/android/ime.rs @@ -0,0 +1,272 @@ +//! `InputConnection`, implemented directly against a focused `TextEdit` +//! rather than against a stand-in editor the way android-view's own demo +//! does over its `parley::PlainEditor` -- I1 already put parley behind +//! `TextEdit`, so this is that same bridge, just wired to iris's widget +//! instead of a bespoke one. Follows `demo/src/lib.rs`'s +//! `impl InputConnection for DemoViewPeer`, which is where RUST.md's E1 +//! found the shape this needs (`text_before_cursor` is what gets Gboard's +//! suggestion strip to read real words out of the buffer). +//! +//! Two things the demo tracks that this does not, both noted rather than +//! silently dropped: a real "composing region" distinct from the +//! selection (`set_composing_region` here just moves the caret, since +//! `TextEdit` has no third range to hold one), and batch-edit coalescing +//! (`begin`/`end_batch_edit` are no-ops -- a redraw mid-batch costs a frame +//! it does not need to, not correctness). + +use crate::prelude::*; +use android_view::{ + CAP_MODE_SENTENCES, CallbackCtx, EditorInfo, IME_FLAG_NO_ENTER_ACTION, IME_FLAG_NO_EXTRACT_UI, + IME_FLAG_NO_FULLSCREEN, INPUT_TYPE_CLASS_TEXT, INPUT_TYPE_TEXT_FLAG_AUTO_CORRECT, + INPUT_TYPE_TEXT_FLAG_CAP_SENTENCES, INPUT_TYPE_TEXT_FLAG_MULTI_LINE, InputConnection, + caps_mode, +}; +use std::borrow::Cow; + +use super::view::{AndroidAppState, IrisViewPeer}; + +/// Byte offset -> UTF-16 code unit offset, the unit every `InputConnection` +/// method speaks in (Java strings are UTF-16). `TextEdit` is byte-indexed +/// throughout since I1 moved it to parley -- see `edit.rs`'s doc comment on +/// `text()` -- so every crossing of this boundary goes through here rather +/// than through ad hoc counting at each call site. +fn byte_to_utf16(text: &str, byte_idx: usize) -> usize { + text[..byte_idx].encode_utf16().count() +} + +fn utf16_to_byte(text: &str, utf16_idx: usize) -> usize { + let mut utf16_len = 0; + for (byte_idx, ch) in text.char_indices() { + if utf16_len >= utf16_idx { + return byte_idx; + } + utf16_len += ch.len_utf16(); + } + text.len() +} + +impl IrisViewPeer { + fn focus(&self) -> Option> { + self.state.android_state().focus + } +} + +impl InputConnection for IrisViewPeer { + fn on_create_input_connection<'local>( + &mut self, + ctx: &mut CallbackCtx<'local>, + out_attrs: &EditorInfo<'local>, + ) { + // Set once per `InputConnection`, not per field -- Android calls + // this when the view (not a particular widget) attaches to an + // IME. `MULTI_LINE`/`AUTO_CORRECT`/`CAP_SENTENCES` cover both the + // tabs example's composer and a plain single-line field well + // enough that no per-field variant is worth the extra state yet. + out_attrs.set_input_type( + &mut ctx.env, + INPUT_TYPE_CLASS_TEXT + | INPUT_TYPE_TEXT_FLAG_CAP_SENTENCES + | INPUT_TYPE_TEXT_FLAG_AUTO_CORRECT + | INPUT_TYPE_TEXT_FLAG_MULTI_LINE, + ); + out_attrs.set_ime_options( + &mut ctx.env, + IME_FLAG_NO_FULLSCREEN | IME_FLAG_NO_EXTRACT_UI | IME_FLAG_NO_ENTER_ACTION, + ); + if let Some(focus) = self.focus() { + let text = &self.rsc[focus]; + let sel = text.selection_range().unwrap_or(0..0); + let start = byte_to_utf16(text.text(), sel.start) as i32; + let end = byte_to_utf16(text.text(), sel.end) as i32; + out_attrs.set_initial_sel_start(&mut ctx.env, start); + out_attrs.set_initial_sel_end(&mut ctx.env, end); + let caps = caps_mode( + &mut ctx.env, + text.text(), + start as usize, + CAP_MODE_SENTENCES, + ); + out_attrs.set_initial_caps_mode(&mut ctx.env, caps); + } + } + + fn text_before_cursor<'slf>( + &'slf mut self, + _ctx: &mut CallbackCtx, + n: i32, + ) -> Option> { + if n < 0 { + return None; + } + let focus = self.focus()?; + let text = &self.rsc[focus]; + let sel = text.selection_range()?; + let end_16 = byte_to_utf16(text.text(), sel.start); + let start_16 = end_16.saturating_sub(n as usize); + let start = utf16_to_byte(text.text(), start_16); + Some(Cow::Borrowed(&text.text()[start..sel.start])) + } + + fn text_after_cursor<'slf>( + &'slf mut self, + _ctx: &mut CallbackCtx, + n: i32, + ) -> Option> { + if n < 0 { + return None; + } + let focus = self.focus()?; + let text = &self.rsc[focus]; + let sel = text.selection_range()?; + let len_16 = byte_to_utf16(text.text(), text.text().len()); + let start_16 = byte_to_utf16(text.text(), sel.end); + let end_16 = (start_16 + n as usize).min(len_16); + let end = utf16_to_byte(text.text(), end_16); + Some(Cow::Borrowed(&text.text()[sel.end..end])) + } + + fn selected_text<'slf>(&'slf mut self, _ctx: &mut CallbackCtx) -> Option> { + let focus = self.focus()?; + Some(Cow::Owned(self.rsc[focus].selected_text()?)) + } + + fn cursor_caps_mode(&mut self, ctx: &mut CallbackCtx, req_modes: u32) -> u32 { + let Some(focus) = self.focus() else { + return 0; + }; + let text = &self.rsc[focus]; + let Some(caret) = text.caret() else { + return 0; + }; + let off = byte_to_utf16(text.text(), caret); + caps_mode(&mut ctx.env, text.text(), off, req_modes) + } + + fn delete_surrounding_text( + &mut self, + ctx: &mut CallbackCtx, + before_length: i32, + after_length: i32, + ) -> bool { + let Some(focus) = self.focus() else { + return false; + }; + let text = &self.rsc[focus]; + let Some(sel) = text.selection_range() else { + return false; + }; + let content = text.text(); + let start_16 = + byte_to_utf16(content, sel.start).saturating_sub(before_length.max(0) as usize); + let len_16 = byte_to_utf16(content, content.len()); + let end_16 = (byte_to_utf16(content, sel.end) + after_length.max(0) as usize).min(len_16); + let start = utf16_to_byte(content, start_16); + let end = utf16_to_byte(content, end_16); + focus.edit(&mut self.rsc).delete_byte_range(start, end); + self.after_input(ctx); + true + } + + fn delete_surrounding_text_in_code_points( + &mut self, + ctx: &mut CallbackCtx, + before_length: i32, + after_length: i32, + ) -> bool { + // Approximated as UTF-16 units rather than Unicode scalar values -- + // the two differ only outside the Basic Multilingual Plane, which + // this widget tree does not exercise today. Worth revisiting if a + // field ever needs to edit emoji or other astral-plane text well. + self.delete_surrounding_text(ctx, before_length, after_length) + } + + fn set_composing_text( + &mut self, + ctx: &mut CallbackCtx, + text: &str, + _new_cursor_position: i32, + ) -> bool { + let Some(focus) = self.focus() else { + return false; + }; + // The IME re-sends its whole composition on every keystroke; + // `compose_len` (chars, not bytes -- `TextEditCtx::replace`'s unit) + // is what lets `replace` remove exactly what it inserted last time. + // The same shape as `default::DefaultApp`'s `Ime::Preedit` handling + // for winit. + let compose_len = self.state.android_state().compose_len; + focus.edit(&mut self.rsc).replace(compose_len, text); + self.state.android_state_mut().compose_len = text.chars().count(); + self.after_input(ctx); + true + } + + fn set_composing_region(&mut self, _ctx: &mut CallbackCtx, _start: i32, _end: i32) -> bool { + // `TextEdit` has no separate composing range to move -- see this + // module's doc comment. Declining (rather than moving the caret, + // which would surprise a caller expecting only a style change) + // is the safer approximation. + false + } + + fn finish_composing_text(&mut self, ctx: &mut CallbackCtx) -> bool { + self.state.android_state_mut().compose_len = 0; + self.after_input(ctx); + true + } + + fn set_selection(&mut self, ctx: &mut CallbackCtx, start: i32, end: i32) -> bool { + let Some(focus) = self.focus() else { + return false; + }; + let text = &self.rsc[focus]; + let content = text.text(); + // Collapsed to `end`: `TextEditCtx` has no range-selection setter + // yet (nothing before I2 needed one), so an IME-driven selection + // lands the caret at its focus end rather than spanning both. + let byte = utf16_to_byte(content, end.max(0) as usize); + focus.edit(&mut self.rsc).set_cursor_byte(byte); + let _ = start; + self.after_input(ctx); + true + } + + fn perform_editor_action(&mut self, _ctx: &mut CallbackCtx, _editor_action: i32) -> bool { + // `IME_FLAG_NO_ENTER_ACTION` above asks the IME not to offer one; + // nothing here needs handling it yet. + false + } + + fn begin_batch_edit(&mut self, _ctx: &mut CallbackCtx) -> bool { + true + } + + fn end_batch_edit(&mut self, _ctx: &mut CallbackCtx) -> bool { + true + } + + fn send_key_event<'local>( + &mut self, + ctx: &mut CallbackCtx<'local>, + event: &android_view::KeyEvent<'local>, + ) -> bool { + let key_code = event.key_code(&mut ctx.env); + let handled = super::input::on_key( + &mut self.rsc, + &mut self.state, + &mut ctx.env, + key_code, + event, + ); + if handled { + self.after_input(ctx); + } + handled + } + + fn request_cursor_updates(&mut self, _ctx: &mut CallbackCtx, _cursor_update_mode: i32) -> bool { + // No cursor-anchor UI to feed -- see RUST.md's I2 notes on what + // this backend does not do yet. + false + } +} diff --git a/iris/src/android/input.rs b/iris/src/android/input.rs new file mode 100644 index 0000000..963675e --- /dev/null +++ b/iris/src/android/input.rs @@ -0,0 +1,39 @@ +use crate::prelude::*; +use android_view::{jni::JNIEnv, ndk::event::Keycode}; + +use super::view::{AndroidAppState, AndroidRsc}; + +/// Hardware/synthesized key handling for the field that currently has +/// focus. Most typing on Android goes through the IME's `InputConnection` +/// (`android/ime.rs`) instead -- this only sees what a soft keyboard still +/// sends as a real `KeyEvent` in "not fullscreen" mode (Backspace, Enter, +/// the arrow keys on a physical keyboard) plus whatever `unicode_char` +/// reports for a plain key press. Returns whether anything used the event. +pub(super) fn on_key<'local, State: AndroidAppState>( + rsc: &mut AndroidRsc, + state: &mut State, + env: &mut JNIEnv<'local>, + key_code: Keycode, + event: &android_view::KeyEvent<'local>, +) -> bool { + let Some(focus) = state.android_state().focus else { + return false; + }; + let mut text = focus.edit(rsc); + match key_code { + Keycode::Del => text.backspace(false), + Keycode::ForwardDel => text.delete(false), + Keycode::DpadLeft => text.motion(Motion::Left, false), + Keycode::DpadRight => text.motion(Motion::Right, false), + Keycode::DpadUp => text.motion(Motion::Up, false), + Keycode::DpadDown => text.motion(Motion::Down, false), + Keycode::MoveHome => text.motion(Motion::LineStart, false), + Keycode::MoveEnd => text.motion(Motion::LineEnd, false), + Keycode::Enter | Keycode::NumpadEnter => text.newline(), + _ => match event.unicode_char(env) { + Some(c) if !c.is_control() => text.insert(&c.to_string()), + _ => return false, + }, + } + true +} diff --git a/iris/src/android/insets.rs b/iris/src/android/insets.rs new file mode 100644 index 0000000..6f4383f --- /dev/null +++ b/iris/src/android/insets.rs @@ -0,0 +1,129 @@ +//! Window insets, fed in from outside `ViewPeer`. +//! +//! android-view's registered native methods (`view.rs` in that crate) cover +//! touch, keys, focus, the surface and the IME -- there is nothing for +//! `View.onApplyWindowInsets`, because android-view's own demo does not +//! need it. The back gesture needed no new plumbing at all: with no +//! `OnBackPressedCallback` registered, Android still delivers it as an +//! ordinary `KEYCODE_BACK` `KeyEvent` through the ordinary key path (see +//! `view.rs`'s `on_key_down`), which is the legacy behaviour every app gets +//! by default and is enough for "the back gesture as an event". Insets have +//! no such stand-in, so this module registers one more native method by +//! hand, on the app's own `View` subclass rather than on android-view's. +//! +//! The peer id android-view hands back from `register_view_peer` is opaque +//! outside that crate (`with_peer` is `pub(crate)` there), so there is no +//! way to reach an existing `IrisViewPeer` from a JNI entry point we define +//! ourselves. Instead of forking android-view to add a hook, `new_peer` +//! (`view.rs`) inserts the *same* id into this module's own map, pointing +//! at a plain `Rc>` cloned into `AndroidUiState` too -- +//! so writing here is reading there, with no dependency in either +//! direction on the other's internals. + +use android_view::{ + View, + jni::{ + JNIEnv, NativeMethod, + descriptors::Desc, + objects::JClass, + sys::{jint, jlong}, + }, +}; +use std::{ + cell::RefCell, + collections::HashMap, + ffi::c_void, + rc::Rc, + sync::{Mutex, OnceLock}, +}; + +use send_wrapper::SendWrapper; + +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)] +pub struct Insets { + pub left: i32, + pub top: i32, + pub right: i32, + pub bottom: i32, + /// The keyboard's own inset (`WindowInsetsCompat.Type.ime()`), separate + /// from `bottom` (the system bars): a layout wants to know about the + /// keyboard specifically, since it usually means "make room" rather + /// than "stay clear of a corner". + pub ime_bottom: i32, +} + +#[derive(Default)] +pub struct Shared { + pub insets: Insets, +} + +type SharedMap = HashMap>>>; + +fn map() -> &'static Mutex { + static MAP: OnceLock> = OnceLock::new(); + MAP.get_or_init(Default::default) +} + +/// Called from `view::new_peer` with the same id android-view's +/// `register_view_peer` returned, so a later `apply_window_insets` call +/// (keyed on that id by Java, which only ever sees the one long) reaches +/// the same `Shared` cell `AndroidUiState` reads from. +pub(super) fn register(id: jlong, shared: Rc>) { + map().lock().unwrap().insert(id, SendWrapper::new(shared)); +} + +extern "system" fn unregister_insets<'local>( + _env: JNIEnv<'local>, + _view: View<'local>, + peer: jlong, +) { + map().lock().unwrap().remove(&peer); +} + +extern "system" fn apply_window_insets<'local>( + mut env: JNIEnv<'local>, + view: View<'local>, + peer: jlong, + left: jint, + top: jint, + right: jint, + bottom: jint, + ime_bottom: jint, +) { + if let Some(shared) = map().lock().unwrap().get(&peer) { + shared.borrow_mut().insets = Insets { + left, + top, + right, + bottom, + ime_bottom, + }; + } + // Insets can change (the keyboard opening) with no resize and no + // touch, so nothing else here would otherwise ask for a frame. + view.post_frame_callback(&mut env); +} + +/// Registers `applyWindowInsetsNative` on the app's own `View` subclass. +/// Called once from `JNI_OnLoad` alongside `android_view::register_view_class`. +pub fn register_native_methods<'local, 'other_local>( + env: &mut JNIEnv<'local>, + class: impl Desc<'local, JClass<'other_local>>, +) { + env.register_native_methods( + class, + &[ + NativeMethod { + name: "applyWindowInsetsNative".into(), + sig: "(JIIIII)V".into(), + fn_ptr: apply_window_insets as *mut c_void, + }, + NativeMethod { + name: "unregisterInsetsNative".into(), + sig: "(J)V".into(), + fn_ptr: unregister_insets as *mut c_void, + }, + ], + ) + .unwrap(); +} diff --git a/iris/src/android/mod.rs b/iris/src/android/mod.rs new file mode 100644 index 0000000..df4c0a1 --- /dev/null +++ b/iris/src/android/mod.rs @@ -0,0 +1,42 @@ +//! iris's second windowing backend: `android-view` (a `SurfaceView` plus a +//! JNI `ViewPeer`) instead of winit. See RUST.md's I2 for why this exists +//! as a second backend rather than winit's own (unfinished, and blocked on +//! `android-activity`'s backend-feature requirement) Android support, and +//! for the pass condition this was built against. +//! +//! Structured to mirror `default/` module for module: `view.rs` is that +//! module's `app.rs` + `state.rs` combined (android-view has one harness +//! type, `ViewPeer`, where winit splits `ApplicationHandler` from the +//! per-window state), `render.rs` is `render.rs`, `input.rs` is `input.rs`, +//! `attr.rs` is `attr.rs`. `ime.rs` and `insets.rs` have no winit +//! counterpart: winit cannot drive an IME beyond `Ime::Preedit`/`Commit` +//! (RUST.md's E1) and has no concept of Android's window insets at all. + +mod attr; +mod ime; +mod input; +mod insets; +mod render; +mod view; + +pub use insets::Insets; +pub use render::AndroidRenderer; +pub use view::{ + AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState, IrisViewPeer, new_peer, +}; + +/// Registers the extra native methods this backend needs beyond what +/// `android_view::register_view_class` covers (window insets -- see +/// `insets.rs`'s doc comment for why that one could not ride along on an +/// existing android-view callback the way the back gesture does). Call +/// from `JNI_OnLoad` alongside `register_view_class`, on the same `View` +/// subclass. +pub fn register_native_methods<'local, 'other_local>( + env: &mut android_view::jni::JNIEnv<'local>, + class: impl android_view::jni::descriptors::Desc< + 'local, + android_view::jni::objects::JClass<'other_local>, + >, +) { + insets::register_native_methods(env, class); +} diff --git a/iris/src/android/render.rs b/iris/src/android/render.rs new file mode 100644 index 0000000..f008cb2 --- /dev/null +++ b/iris/src/android/render.rs @@ -0,0 +1,194 @@ +use crate::task::RequestRedraw; +use android_view::{ + View, + jni::{JavaVM, objects::GlobalRef}, + ndk::native_window::NativeWindow, +}; +use iris_core::{UiData, UiRenderNode, UiRenderState}; +use pollster::FutureExt; +use wgpu::{ + rwh::{DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WindowHandle}, + *, +}; + +pub const CLEAR_COLOR: Color = Color::BLACK; + +/// `NativeWindow` (from the surface android-view hands over in +/// `surfaceChanged`) has a window handle but not a display one -- there is +/// exactly one display on Android and `rwh` has a unit variant for it. +/// Mirrors android-view's own demo (`demo/src/lib.rs`'s +/// `AndroidWindowHandle`). +struct AndroidWindowHandle { + window: NativeWindow, +} + +impl HasDisplayHandle for AndroidWindowHandle { + fn display_handle(&self) -> Result, HandleError> { + Ok(DisplayHandle::android()) + } +} + +impl HasWindowHandle for AndroidWindowHandle { + fn window_handle(&self) -> Result, HandleError> { + self.window.window_handle() + } +} + +/// The android-view surface, unlike winit's window, does not outlive a +/// backgrounding of the activity: `surfaceDestroyed`/`surfaceCreated` (via +/// `SurfaceHolder.Callback`) recreate it, so this holds everything that +/// depends on that surface rather than being built once at startup -- +/// `AndroidUiState` holds it as `Option`, `None` exactly +/// when there is no surface to draw into. +pub struct AndroidRenderer { + surface: Surface<'static>, + device: Device, + queue: Queue, + config: SurfaceConfiguration, + encoder: CommandEncoder, + pub ui: UiRenderNode, +} + +impl AndroidRenderer { + pub fn new(window: NativeWindow, width: u32, height: u32) -> Self { + let instance = Instance::new(&InstanceDescriptor { + backends: Backends::PRIMARY, + ..Default::default() + }); + + // SAFETY: the `NativeWindow` outlives the surface built from it -- + // android-view drops the old renderer (and this surface with it) + // before handing over a new window, in `surface_changed` below. + let surface = instance + .create_surface(SurfaceTarget::from(AndroidWindowHandle { window })) + .expect("Could not create android surface!"); + + let adapter = instance + .request_adapter(&RequestAdapterOptions { + power_preference: PowerPreference::default(), + compatible_surface: Some(&surface), + force_fallback_adapter: false, + }) + .block_on() + .expect("Could not get adapter!"); + + // Same request as the winit backend's `UiRenderer::new` -- no + // binding-array features, see TEXTURES.md's "Recommended shape". + let (device, queue) = adapter + .request_device(&DeviceDescriptor { + required_limits: Limits { + max_buffer_size: 1 << 30, + ..Default::default() + }, + ..Default::default() + }) + .block_on() + .expect("Could not get device!"); + + let surface_caps = surface.get_capabilities(&adapter); + let surface_format = surface_caps + .formats + .iter() + .copied() + .find(|f| f.is_srgb()) + .unwrap_or(surface_caps.formats[0]); + + let config = SurfaceConfiguration { + usage: TextureUsages::RENDER_ATTACHMENT, + format: surface_format, + width, + height, + present_mode: PresentMode::AutoVsync, + alpha_mode: surface_caps.alpha_modes[0], + desired_maximum_frame_latency: 2, + view_formats: vec![], + }; + surface.configure(&device, &config); + + let encoder = Self::create_encoder(&device); + let ui = UiRenderNode::new(&device, &queue, &config); + + Self { + surface, + device, + queue, + config, + encoder, + ui, + } + } + + fn create_encoder(device: &Device) -> CommandEncoder { + device.create_command_encoder(&CommandEncoderDescriptor { + label: Some("Render Encoder"), + }) + } + + pub fn update(&mut self, ui: &mut UiData, render: &mut UiRenderState) { + self.ui.update(&self.device, &self.queue, ui, render); + } + + pub fn draw(&mut self) { + let output = self.surface.get_current_texture().unwrap(); + let view = output + .texture + .create_view(&TextureViewDescriptor::default()); + + let mut encoder = std::mem::replace(&mut self.encoder, Self::create_encoder(&self.device)); + { + let render_pass = &mut encoder.begin_render_pass(&RenderPassDescriptor { + color_attachments: &[Some(RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: Operations { + load: LoadOp::Clear(CLEAR_COLOR), + store: StoreOp::Store, + }, + depth_slice: None, + })], + ..Default::default() + }); + self.ui.draw(render_pass); + } + + self.queue.submit(std::iter::once(encoder.finish())); + output.present(); + } + + pub fn size(&self) -> iris_core::util::Vec2 { + (self.config.width, self.config.height).into() + } + + pub fn resize(&mut self, width: u32, height: u32) { + self.config.width = width; + self.config.height = height; + self.surface.configure(&self.device, &self.config); + self.ui.resize((width, height), &self.queue); + } +} + +/// `Tasks`' redraw handle on Android: a background task finishes on the +/// tokio thread `Tasks::init` spawned, which is not attached to the JVM, so +/// asking for a frame means attaching first. `post_frame_callback` needs a +/// live `View` reference; the global ref is what survives past the JNI call +/// that handed it to us. +pub struct AndroidRedrawHandle { + vm: JavaVM, + view: GlobalRef, +} + +impl AndroidRedrawHandle { + pub fn new(vm: JavaVM, view: GlobalRef) -> Self { + Self { vm, view } + } +} + +impl RequestRedraw for AndroidRedrawHandle { + fn request_redraw(&self) { + let Ok(mut env) = self.vm.attach_current_thread() else { + return; + }; + let local = env.new_local_ref(&self.view).unwrap(); + View(local).post_frame_callback(&mut env); + } +} diff --git a/iris/src/android/view.rs b/iris/src/android/view.rs new file mode 100644 index 0000000..311dd24 --- /dev/null +++ b/iris/src/android/view.rs @@ -0,0 +1,404 @@ +use crate::prelude::*; +use crate::task::RequestRedraw; +use android_view::{ + CallbackCtx, Context, InputConnection, KeyEvent, MotionEvent, Rect, View, ViewPeer, + jni::JNIEnv, + ndk::event::{Keycode, MotionAction}, +}; +// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the +// `Sized` *widget* (`widget::position::sized::Sized`), and an unqualified +// glob import shadows the language prelude -- `default/mod.rs` has the same +// explicit import for the same reason. +use std::{ + cell::RefCell, + marker::{PhantomData, Sized}, + rc::Rc, + sync::Arc, + time::Instant, +}; + +use super::{ + insets::{Insets, Shared}, + render::{AndroidRedrawHandle, AndroidRenderer}, +}; + +/// The android-view analogue of `default::DefaultUiState`. `renderer` is an +/// `Option` because a `SurfaceView`'s surface does not outlive backgrounding +/// the way a winit `Window` does -- `surfaceDestroyed`/`surfaceCreated` can +/// happen any number of times over the life of one `IrisViewPeer`. +pub struct AndroidUiState { + pub root: Option, + pub renderer: Option, + pub focus: Option>, + pub cursor: CursorState, + pub last_click: Instant, + /// The IME preedit's previous length, in `char`s -- the same + /// re-send-the-whole-composition bookkeeping `default::DefaultUiState` + /// keeps for winit's `Ime::Preedit`, since android-view's + /// `setComposingText` has the identical shape (see `android/ime.rs`). + pub compose_len: usize, + /// Set by `attr::FocusHost::focus_gained` when a `TextEdit` is focused; + /// consumed by the touch handler after the sensor pass finishes, since + /// showing the keyboard is a JNI call and `focus_gained` runs deep + /// inside the platform-agnostic sensor dispatch with no `CallbackCtx` + /// in reach. + pub pending_show_keyboard: bool, + /// Window insets, filled in from outside the normal `ViewPeer` callback + /// path -- see `android/insets.rs` for why they need a registry of + /// their own. + shared: Rc>, +} + +impl AndroidUiState { + fn new(shared: Rc>) -> Self { + Self { + root: None, + renderer: None, + focus: None, + cursor: Default::default(), + last_click: Instant::now(), + compose_len: 0, + pending_show_keyboard: false, + shared, + } + } + + pub fn insets(&self) -> Insets { + self.shared.borrow().insets + } +} + +impl HasRoot for AndroidUiState { + fn set_root(&mut self, root: StrongWidget) { + self.root = Some(root); + } +} + +pub trait HasAndroidUiState: Sized + 'static { + fn android_state(&self) -> &AndroidUiState; + fn android_state_mut(&mut self) -> &mut AndroidUiState; +} + +pub trait AndroidAppState: HasAndroidUiState { + fn new(ui_state: AndroidUiState, rsc: &mut AndroidRsc) -> Self; + /// The system back gesture/button. `true` means handled -- nothing + /// further happens; `false` lets the activity finish as it would with + /// no view at all. The default declines, since most screens have + /// nothing to intercept it for. + #[allow(unused_variables)] + fn back_pressed(&mut self, rsc: &mut AndroidRsc, render: &mut UiRenderState) -> bool { + false + } +} + +/// The android-view analogue of `default::DefaultRsc` -- identical in +/// substance, since none of `UiRsc`/`HasEvents`/`HasTasks`/`HasWidgetState` +/// mention winit. Kept as a separate type rather than shared code because +/// the two backends' `ViewPeer`/`ApplicationHandler` entry points hold +/// their harness state differently (see RUST.md's I2). +pub struct AndroidRsc { + pub ui: UiData, + pub events: EventManager, + pub tasks: Tasks, + pub state: WidgetState, + _state: PhantomData, +} + +impl AndroidRsc { + pub fn create_state(&mut self, id: impl IdLike, data: T) -> WeakState { + self.state.add(id.id(), data) + } +} + +impl UiRsc for AndroidRsc { + fn ui(&self) -> &UiData { + &self.ui + } + fn ui_mut(&mut self) -> &mut UiData { + &mut self.ui + } + fn on_draw(&mut self, active: &ActiveData) { + self.events.draw(active); + } + fn on_undraw(&mut self, active: &ActiveData) { + self.events.undraw(active); + } + fn on_remove(&mut self, id: WidgetId) { + self.events.remove(id); + self.state.remove(id); + } +} + +impl HasState for AndroidRsc { + type State = State; +} + +impl HasEvents for AndroidRsc { + fn events(&self) -> &EventManager { + &self.events + } + fn events_mut(&mut self) -> &mut EventManager { + &mut self.events + } +} + +impl HasTasks for AndroidRsc { + fn tasks_mut(&mut self) -> &mut Tasks { + &mut self.tasks + } +} + +impl HasWidgetState for AndroidRsc { + fn widget_state(&self) -> &WidgetState { + &self.state + } + fn widget_state_mut(&mut self) -> &mut WidgetState { + &mut self.state + } +} + +/// The `ViewPeer` android-view dispatches every callback to. One per +/// `RustView` instance; `new_peer` (below) builds it and hands the id to +/// Java the same way android-view's own demo does. +pub struct IrisViewPeer { + pub(super) rsc: AndroidRsc, + pub(super) render: UiRenderState, + pub(super) state: State, + task_recv: TaskMsgReceiver>, +} + +impl>> std::ops::Index for AndroidRsc { + type Output = I::Output; + + fn index(&self, index: I) -> &Self::Output { + index.get(self) + } +} + +impl>> std::ops::IndexMut for AndroidRsc { + fn index_mut(&mut self, index: I) -> &mut Self::Output { + index.get_mut(self) + } +} + +impl IrisViewPeer { + fn drain_tasks(&mut self) { + while let Ok(update) = self.task_recv.try_recv() { + update(&mut self.state, &mut self.rsc); + } + } + + /// Common tail for every callback that might have changed the cursor, + /// the text focus, or the widget tree: run the sensors that touch + /// input feeds, then ask for a frame if the result needs drawing. + /// Mirrors `default::DefaultApp::window_event`'s tail, split across + /// android-view's several entry points instead of winit's one. + pub(super) fn after_input(&mut self, ctx: &mut CallbackCtx) { + let window_size = self.window_size(); + let ui_state = self.state.android_state_mut(); + let cursor = ui_state.cursor.clone(); + let old_focus = ui_state.focus; + self.render + .run_sensors(&mut self.rsc, &mut self.state, cursor, window_size); + + let ui_state = self.state.android_state_mut(); + if old_focus != ui_state.focus + && let Some(old) = old_focus + { + old.edit(&mut self.rsc).deselect(); + } + if std::mem::take(&mut ui_state.pending_show_keyboard) { + show_soft_input(&mut ctx.env, &ctx.view); + } + + let ui_state = self.state.android_state_mut(); + ui_state.cursor.end_frame(); + if self.render.needs_redraw(&ui_state.root, self.rsc.widgets()) { + ctx.view.post_frame_callback(&mut ctx.env); + } + } + + fn window_size(&self) -> Vec2 { + let ui_state = self.state.android_state(); + match &ui_state.renderer { + Some(r) => r.size(), + None => Vec2::ZERO, + } + } + + fn render(&mut self) { + let ui_state = self.state.android_state(); + if ui_state.renderer.is_none() { + return; + } + let ui_state = self.state.android_state_mut(); + self.render.update(&ui_state.root, &mut self.rsc); + let ui_state = self.state.android_state_mut(); + let Some(renderer) = &mut ui_state.renderer else { + return; + }; + renderer.update(&mut self.rsc.ui, &mut self.render); + renderer.draw(); + } +} + +fn show_soft_input<'local>(env: &mut JNIEnv<'local>, view: &View<'local>) { + let imm = view.input_method_manager(env); + imm.show_soft_input(env, view, 0); +} + +impl ViewPeer for IrisViewPeer { + fn on_key_down<'local>( + &mut self, + ctx: &mut CallbackCtx<'local>, + key_code: Keycode, + event: &KeyEvent<'local>, + ) -> bool { + self.drain_tasks(); + // With no `OnBackPressedCallback` registered on the Java side, the + // system still delivers the back gesture as a synthetic + // `KEYCODE_BACK` through this same path -- the legacy behaviour + // every view-based app gets by default, and enough for "the back + // gesture as an event" without a second JNI registry. See + // `android/insets.rs`'s doc comment for why insets could not take + // the same shortcut. + if key_code == Keycode::Back { + let handled = self.state.back_pressed(&mut self.rsc, &mut self.render); + if handled { + self.after_input(ctx); + } + return handled; + } + let handled = super::input::on_key( + &mut self.rsc, + &mut self.state, + &mut ctx.env, + key_code, + event, + ); + if handled { + self.after_input(ctx); + } + handled + } + + fn on_touch_event<'local>( + &mut self, + ctx: &mut CallbackCtx<'local>, + event: &MotionEvent<'local>, + ) -> bool { + self.drain_tasks(); + let action = event.action_masked(&mut ctx.env); + let x = event.x(&mut ctx.env); + let y = event.y(&mut ctx.env); + let ui_state = self.state.android_state_mut(); + match action { + MotionAction::Down => { + ui_state.cursor.pos = vec2(x, y); + ui_state.cursor.exists = true; + ui_state.cursor.buttons.left.update(true); + } + MotionAction::Move => { + ui_state.cursor.pos = vec2(x, y); + } + MotionAction::Up | MotionAction::Cancel => { + ui_state.cursor.pos = vec2(x, y); + ui_state.cursor.buttons.left.update(false); + } + _ => return false, + } + self.after_input(ctx); + true + } + + fn on_focus_changed<'local>( + &mut self, + ctx: &mut CallbackCtx<'local>, + gain_focus: bool, + _direction: i32, + _previously_focused_rect: Option<&Rect<'local>>, + ) { + self.drain_tasks(); + if !gain_focus { + let ui_state = self.state.android_state_mut(); + if let Some(focus) = ui_state.focus.take() { + focus.edit(&mut self.rsc).deselect(); + } + } + self.after_input(ctx); + } + + fn on_attached_to_window(&mut self, _ctx: &mut CallbackCtx) { + self.drain_tasks(); + } + + fn surface_changed<'local>( + &mut self, + ctx: &mut CallbackCtx<'local>, + holder: &android_view::SurfaceHolder<'local>, + _format: i32, + width: i32, + height: i32, + ) { + self.drain_tasks(); + let window = holder.surface(&mut ctx.env).to_native_window(&mut ctx.env); + // Drop the old renderer (and the surface it owns) before building + // one from the new window -- see `AndroidRenderer`'s doc comment. + let ui_state = self.state.android_state_mut(); + ui_state.renderer = None; + ui_state.renderer = Some(AndroidRenderer::new(window, width as u32, height as u32)); + self.render(); + } + + fn surface_destroyed<'local>( + &mut self, + _ctx: &mut CallbackCtx<'local>, + _holder: &android_view::SurfaceHolder<'local>, + ) { + self.state.android_state_mut().renderer = None; + } + + fn do_frame(&mut self, _ctx: &mut CallbackCtx, _frame_time_nanos: i64) { + self.drain_tasks(); + self.render(); + } + + fn as_input_connection(&mut self) -> Option<&mut dyn InputConnection> { + Some(self) + } +} + +/// Registers `IrisViewPeer`'s native methods and builds one on every +/// `newViewPeer` call from Java. `State`'s app crate wraps this in a +/// concrete `extern "system" fn` (a generic function cannot be handed to +/// `register_view_class`, which wants a plain function pointer) -- see +/// `iris/android-app/src/lib.rs`. +pub fn new_peer<'local, State: AndroidAppState>( + env: JNIEnv<'local>, + view: View<'local>, + _context: Context<'local>, +) -> android_view::jni::sys::jlong { + let vm = env.get_java_vm().unwrap(); + let global_view = env.new_global_ref(&view.0).unwrap(); + let redraw: Arc = Arc::new(AndroidRedrawHandle::new(vm, global_view)); + let (tasks, task_recv) = Tasks::init(redraw); + let mut rsc = AndroidRsc { + ui: Default::default(), + events: Default::default(), + tasks, + state: Default::default(), + _state: PhantomData, + }; + let shared = Rc::new(RefCell::new(Shared::default())); + let ui_state = AndroidUiState::new(shared.clone()); + let state = State::new(ui_state, &mut rsc); + let peer = IrisViewPeer { + rsc, + render: UiRenderState::new(), + state, + task_recv, + }; + let id = android_view::register_view_peer(peer); + super::insets::register(id, shared); + id +} diff --git a/iris/src/attr.rs b/iris/src/attr.rs new file mode 100644 index 0000000..29acb9b --- /dev/null +++ b/iris/src/attr.rs @@ -0,0 +1,105 @@ +use crate::prelude::*; +use std::time::{Duration, Instant}; + +/// What focusing a text field takes from whichever backend is running -- +/// tracked here rather than duplicated per backend, since `Selector` and +/// `Selectable` (below) are the *only* thing that decides which `TextEdit` +/// is the IME's target, and both platforms need the same double-click +/// timing and the same "remember which one" bookkeeping. What differs is +/// what happens *after* the focus record is set: winit tells the +/// compositor an IME area (`focus_gained`, in `default/attr.rs`); on +/// android-view a keyboard has to be asked for explicitly, and only from a +/// JNI call this crate cannot make outside a view callback -- so +/// `focus_gained` there (`android/attr.rs`) just raises a flag the next +/// touch callback consumes. See RUST.md's I2. +pub trait FocusHost { + /// True on a click close enough in time to the previous one to grow a + /// selection instead of starting a new one, updating the clock as a + /// side effect the way a real double-click timer does. + fn recent_click(&mut self) -> bool; + fn set_focus(&mut self, id: Option>); + /// Called after a `TextEdit` becomes the focus target, with the region + /// it was hit in (`None` when the widget could not be located, which + /// happens for one it was just deselected from). + fn focus_gained(&mut self, region: Option); +} + +/// Helper shared by every `FocusHost` impl, so the double-click window is +/// one constant rather than one per backend. +pub fn recent_click(last_click: &mut Instant) -> bool { + let now = Instant::now(); + let recent = (now - *last_click) < Duration::from_millis(300); + *last_click = now; + recent +} + +pub struct Selector; + +impl WidgetAttr for Selector +where + Rsc::State: FocusHost, +{ + type Input = WeakWidget; + + fn run(rsc: &mut Rsc, container: WeakWidget, id: Self::Input) { + rsc.register_event(container, CursorSense::click_or_drag(), move |ctx, rsc| { + let region = ctx.data.render.window_region(&id, &*rsc).unwrap(); + let id_pos = region.top_left; + let container_pos = ctx + .data + .render + .window_region(&container, &*rsc) + .unwrap() + .top_left; + let pos = ctx.data.pos + container_pos - id_pos; + let size = region.size(); + select( + rsc, + ctx.data.render, + ctx.state, + id, + pos, + size, + ctx.data.sense.is_dragging(), + ); + }); + } +} + +pub struct Selectable; + +impl WidgetAttr for Selectable +where + Rsc::State: FocusHost, +{ + type Input = (); + + fn run(rsc: &mut Rsc, id: WeakWidget, _: Self::Input) { + rsc.register_event(id, CursorSense::click_or_drag(), move |ctx, rsc| { + select( + rsc, + ctx.data.render, + ctx.state, + id, + ctx.data.pos, + ctx.data.size, + ctx.data.sense.is_dragging(), + ); + }); + } +} + +fn select( + rsc: &mut impl UiRsc, + render: &UiRenderState, + state: &mut impl FocusHost, + id: WeakWidget, + pos: Vec2, + size: Vec2, + dragging: bool, +) { + let recent = state.recent_click(); + id.edit(rsc).select(pos, size, dragging, recent); + state.set_focus(Some(id)); + state.focus_gained(render.window_region(&id, &*rsc)); +} diff --git a/iris/src/default/attr.rs b/iris/src/default/attr.rs index da442ef..58edb12 100644 --- a/iris/src/default/attr.rs +++ b/iris/src/default/attr.rs @@ -1,83 +1,22 @@ use crate::prelude::*; -use std::time::{Duration, Instant}; use winit::dpi::{LogicalPosition, LogicalSize}; -pub struct Selector; - -impl WidgetAttr for Selector -where - Rsc::State: HasDefaultUiState, -{ - type Input = WeakWidget; - - fn run(rsc: &mut Rsc, container: WeakWidget, id: Self::Input) { - rsc.register_event(container, CursorSense::click_or_drag(), move |ctx, rsc| { - let region = ctx.data.render.window_region(&id, &*rsc).unwrap(); - let id_pos = region.top_left; - let container_pos = ctx - .data - .render - .window_region(&container, &*rsc) - .unwrap() - .top_left; - let pos = ctx.data.pos + container_pos - id_pos; - let size = region.size(); - select( - rsc, - ctx.data.render, - ctx.state, - id, - pos, - size, - ctx.data.sense.is_dragging(), - ); - }); +impl FocusHost for T { + fn recent_click(&mut self) -> bool { + crate::attr::recent_click(&mut self.default_state_mut().last_click) } -} -pub struct Selectable; - -impl WidgetAttr for Selectable -where - Rsc::State: HasDefaultUiState, -{ - type Input = (); - - fn run(rsc: &mut Rsc, id: WeakWidget, _: Self::Input) { - rsc.register_event(id, CursorSense::click_or_drag(), move |ctx, rsc| { - select( - rsc, - ctx.data.render, - ctx.state, - id, - ctx.data.pos, - ctx.data.size, - ctx.data.sense.is_dragging(), - ); - }); + fn set_focus(&mut self, id: Option>) { + self.default_state_mut().focus = id; } -} -fn select( - rsc: &mut impl UiRsc, - render: &UiRenderState, - state: &mut impl HasDefaultUiState, - id: WeakWidget, - pos: Vec2, - size: Vec2, - dragging: bool, -) { - let state = state.default_state_mut(); - let now = Instant::now(); - let recent = (now - state.last_click) < Duration::from_millis(300); - state.last_click = now; - id.edit(rsc).select(pos, size, dragging, recent); - if let Some(region) = render.window_region(&id, &*rsc) { + fn focus_gained(&mut self, region: Option) { + let state = self.default_state_mut(); + let Some(region) = region else { return }; state.window.set_ime_allowed(true); state.window.set_ime_cursor_area( LogicalPosition::::from(region.top_left.tuple()), LogicalSize::::from(region.size().tuple()), ); } - state.focus = Some(id); } diff --git a/iris/src/default/event.rs b/iris/src/default/event.rs deleted file mode 100644 index b33f09e..0000000 --- a/iris/src/default/event.rs +++ /dev/null @@ -1,9 +0,0 @@ -use iris_core::Event; - -#[derive(Eq, PartialEq, Hash, Clone)] -pub struct Submit; -impl Event for Submit {} - -#[derive(Eq, PartialEq, Hash, Clone)] -pub struct Edited; -impl Event for Edited {} diff --git a/iris/src/default/mod.rs b/iris/src/default/mod.rs index ea00eb7..84cba7e 100644 --- a/iris/src/default/mod.rs +++ b/iris/src/default/mod.rs @@ -13,21 +13,12 @@ use winit::{ mod app; mod attr; -mod event; mod input; mod render; -mod sense; -mod state; -mod task; pub use app::*; -pub use attr::*; -pub use event::*; pub use input::*; pub use render::*; -pub use sense::*; -pub use state::*; -pub use task::*; pub type Proxy = EventLoopProxy; @@ -309,12 +300,6 @@ impl AppState for DefaultApp { } } -pub trait RscIdx { - type Output; - fn get(self, rsc: &Rsc) -> &Self::Output; - fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output; -} - impl>> std::ops::Index for DefaultRsc { type Output = I::Output; @@ -328,27 +313,3 @@ impl>> std::ops::IndexMut for Def index.get_mut(self) } } - -impl RscIdx for WeakWidget { - type Output = W; - - fn get(self, rsc: &Rsc) -> &Self::Output { - &rsc.ui().widgets[self] - } - - fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output { - &mut rsc.ui_mut().widgets[self] - } -} - -impl RscIdx for WeakState { - type Output = T; - - fn get(self, rsc: &Rsc) -> &Self::Output { - rsc.widget_state().get(self) - } - - fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output { - rsc.widget_state_mut().get_mut(self) - } -} diff --git a/iris/src/default/render.rs b/iris/src/default/render.rs index 3be551c..74ffd6c 100644 --- a/iris/src/default/render.rs +++ b/iris/src/default/render.rs @@ -1,3 +1,4 @@ +use crate::task::RequestRedraw; use iris_core::{UiData, UiRenderNode, UiRenderState}; use pollster::FutureExt; use std::sync::Arc; @@ -6,6 +7,12 @@ use winit::{dpi::PhysicalSize, window::Window}; pub const CLEAR_COLOR: Color = Color::BLACK; +impl RequestRedraw for Window { + fn request_redraw(&self) { + Window::request_redraw(self); + } +} + pub struct UiRenderer { window: Arc, surface: Surface<'static>, diff --git a/iris/src/event.rs b/iris/src/event.rs index f740f75..cee7f92 100644 --- a/iris/src/event.rs +++ b/iris/src/event.rs @@ -2,7 +2,21 @@ use iris_core::*; use iris_macro::*; use std::sync::Arc; -use crate::default::{TaskCtx, TaskUpdate, Tasks}; +use crate::task::{TaskCtx, TaskUpdate, Tasks}; + +/// A field's Enter key (without a shift, in a multi-line field). Backend +/// input handling raises it directly rather than through `on`, since a +/// field does not know ahead of time whether anything is listening. +#[derive(Eq, PartialEq, Hash, Clone)] +pub struct Submit; +impl Event for Submit {} + +/// A field's content changed as a result of input the backend applied +/// directly to it (a keystroke, an IME commit) rather than through a +/// widget event handler. +#[derive(Eq, PartialEq, Hash, Clone)] +pub struct Edited; +impl Event for Edited {} pub trait Eventable: WidgetLike { fn on( diff --git a/iris/src/lib.rs b/iris/src/lib.rs index af7a6b9..a5e67ad 100644 --- a/iris/src/lib.rs +++ b/iris/src/lib.rs @@ -1,12 +1,29 @@ #![feature(unboxed_closures)] #![feature(fn_traits)] -#![feature(associated_type_defaults)] +// Only `default::DefaultAppState::Event`'s default uses this; unused (and +// warned about) on the android target, which has no such default. +#![cfg_attr(not(target_os = "android"), feature(associated_type_defaults))] #![feature(unsize)] #![feature(option_into_flat_iter)] #![feature(async_fn_traits)] +// Two windowing backends live side by side, chosen by target rather than by +// feature flag: winit everywhere but Android, android-view on it. They are +// mutually exclusive rather than both-compiled-in because winit's own +// Android support pulls in `android-activity`, which needs one of its +// `game-activity`/`native-activity` features selected -- exactly what +// `iris-core` was kept free of, and android-view is the framework's own +// answer to the same surface on that platform. See RUST.md's I2. +#[cfg(target_os = "android")] +pub mod android; +#[cfg(not(target_os = "android"))] pub mod default; + +pub mod attr; pub mod event; +pub mod sense; +pub mod state; +pub mod task; pub mod widget; #[cfg(test)] @@ -19,10 +36,18 @@ pub use iris_macro as macros; pub mod prelude { use super::*; + #[cfg(target_os = "android")] + pub use android::*; + #[cfg(not(target_os = "android"))] pub use default::*; + + pub use attr::*; pub use event::*; pub use iris_core::*; pub use iris_macro::*; + pub use sense::*; + pub use state::*; + pub use task::*; pub use widget::*; pub use iris_core::util::Vec2; diff --git a/iris/src/default/sense.rs b/iris/src/sense.rs similarity index 100% rename from iris/src/default/sense.rs rename to iris/src/sense.rs diff --git a/iris/src/default/state.rs b/iris/src/state.rs similarity index 60% rename from iris/src/default/state.rs rename to iris/src/state.rs index 6a5b0b2..d9cff08 100644 --- a/iris/src/default/state.rs +++ b/iris/src/state.rs @@ -1,7 +1,8 @@ use iris_core::{ - WidgetId, + UiRsc, WidgetId, util::{HashMap, HashSet}, }; +use iris_core::{WeakWidget, Widget}; use std::{ any::{Any, TypeId}, marker::PhantomData, @@ -73,3 +74,39 @@ impl<'a, T: 'static> FnOnce<(&'a mut WidgetState,)> for WeakState { state.get_mut(self) } } + +/// What `Rsc[weak_handle]` indexes through -- one impl per kind of handle +/// (a widget, a piece of per-widget state), shared by both backends' `Rsc` +/// types since indexing a widget tree has nothing to do with windowing. +/// Each backend still needs its own `Index`/`IndexMut for ItsRsc` +/// (`default/mod.rs`, `android/view.rs`), because a blanket impl over every +/// `I: RscIdx` for every possible `Rsc` would conflict between crates. +pub trait RscIdx { + type Output; + fn get(self, rsc: &Rsc) -> &Self::Output; + fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output; +} + +impl RscIdx for WeakWidget { + type Output = W; + + fn get(self, rsc: &Rsc) -> &Self::Output { + &rsc.ui().widgets[self] + } + + fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output { + &mut rsc.ui_mut().widgets[self] + } +} + +impl RscIdx for WeakState { + type Output = T; + + fn get(self, rsc: &Rsc) -> &Self::Output { + rsc.widget_state().get(self) + } + + fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output { + rsc.widget_state_mut().get_mut(self) + } +} diff --git a/iris/src/default/task.rs b/iris/src/task.rs similarity index 71% rename from iris/src/default/task.rs rename to iris/src/task.rs index 36b55c1..ddad795 100644 --- a/iris/src/default/task.rs +++ b/iris/src/task.rs @@ -13,7 +13,17 @@ use tokio::{ unbounded_channel as async_channel, }, }; -use winit::window::Window; + +/// What a completed task nudges when it wants its result drawn. Shared +/// between backends rather than typed as `winit::window::Window` directly: +/// android-view has no `Window` at all, and the redraw request there is a +/// JNI call (`View::post_frame_callback`) rather than a method call on a +/// value this crate owns. Each backend supplies its own implementation -- +/// `default/render.rs` for winit, `android/render.rs` for android-view -- +/// and this module never needs to know which one it is holding. +pub trait RequestRedraw: Send + Sync + 'static { + fn request_redraw(&self); +} pub type TaskMsgSender = SyncSender>>; pub type TaskMsgReceiver = SyncReceiver>>; @@ -23,7 +33,7 @@ impl TaskUpdate pub struct Tasks { start: AsyncSender, - window: Arc, + redraw: Arc, msg_send: SyncSender>>, } @@ -45,7 +55,7 @@ impl TaskCtx { type BoxTask = Pin + Send>>; impl Tasks { - pub fn init(window: Arc) -> (Self, TaskMsgReceiver) { + pub fn init(redraw: Arc) -> (Self, TaskMsgReceiver) { let (start, start_recv) = async_channel(); let (msgs, msgs_recv) = sync_channel(); std::thread::spawn(|| { @@ -56,7 +66,7 @@ impl Tasks { Self { start, msg_send: msgs, - window, + redraw, }, msgs_recv, ) @@ -67,10 +77,10 @@ impl Tasks { F::CallOnceFuture: Send, { let send = self.msg_send.clone(); - let window = self.window.clone(); + let redraw = self.redraw.clone(); let _ = self.start.send(Box::pin(async move { task(TaskCtx::new(send)).await; - window.request_redraw(); + redraw.request_redraw(); })); } } diff --git a/iris/src/widget/text/edit.rs b/iris/src/widget/text/edit.rs index cb45860..656d415 100644 --- a/iris/src/widget/text/edit.rs +++ b/iris/src/widget/text/edit.rs @@ -2,6 +2,7 @@ use crate::prelude::*; use iris_core::{TextData, UiColor}; use parley::{Affinity, Layout, Selection}; use std::ops::{Deref, DerefMut}; +#[cfg(not(target_os = "android"))] use winit::{ event::KeyEvent, keyboard::{Key, NamedKey}, @@ -28,6 +29,7 @@ pub struct TextEdit { /// way to say, since it always denotes some position in the text. A /// collapsed selection is a caret; an uncollapsed one is a span. selection: Option, + #[cfg_attr(target_os = "android", allow(dead_code))] history: Vec<(String, Option)>, double_hit: Option, pub mode: EditMode, @@ -57,6 +59,26 @@ impl TextEdit { } Some(self.buf.text()[sel.text_range()].to_string()) } + + /// The field's content. Byte-indexed, like everything else here since + /// I1 moved to parley -- an IME bridge (`android/ime.rs`) converts to + /// and from UTF-16 code units at its own edge rather than this type + /// knowing about that encoding. + pub fn text(&self) -> &str { + self.view.buf.text() + } + + /// The selection as a byte range, collapsed to `caret..caret` when + /// there is no span. `None` when the field is not focused. + pub fn selection_range(&self) -> Option> { + Some(self.selection?.text_range()) + } + + /// The caret's byte offset -- the focus end of the selection, which is + /// where typing lands regardless of which end of a span it is. + pub fn caret(&self) -> Option { + Some(self.selection?.focus().index()) + } } impl Widget for TextEdit { @@ -115,6 +137,7 @@ impl<'a> TextEditCtx<'a> { } /// Keep the selection valid after the text underneath it changed. + #[cfg_attr(target_os = "android", allow(dead_code))] fn refresh(&mut self) { if let Some(sel) = self.text.selection { let layout = self.layout(); @@ -279,6 +302,20 @@ impl<'a> TextEditCtx<'a> { self.set_caret(start); } + /// The same range delete, exposed for callers that already have byte + /// offsets in hand rather than a `Motion` -- the IME's + /// `deleteSurroundingText`, which android-view hands over in UTF-16 + /// code units that `android/ime.rs` converts before calling this. + pub fn delete_byte_range(&mut self, start: usize, end: usize) { + self.delete_range(start, end); + } + + /// Move the caret to a byte offset, collapsing any selection -- the + /// IME's `setSelection`. + pub fn set_cursor_byte(&mut self, index: usize) { + self.set_caret(index); + } + pub fn select_all(&mut self) { let len = self.text.view.buf.text().len(); if len == 0 { @@ -336,6 +373,7 @@ impl<'a> TextEditCtx<'a> { self.text.double_hit = None; } + #[cfg(not(target_os = "android"))] pub fn apply_event(&mut self, event: &KeyEvent, modifiers: &Modifiers) -> TextInputResult { let old = (self.text.view.buf.text().to_string(), self.text.selection); let mut undo = false; @@ -352,6 +390,7 @@ impl<'a> TextEditCtx<'a> { res } + #[cfg(not(target_os = "android"))] fn apply_event_inner( &mut self, event: &KeyEvent,