iris is the framework alone; the app is one crate in app-rust/
Iris: "the organization of the rust rewrite is a mess right now... there shouldn't be anything related to the app inside of iris. Iris is supposed to be the UI framework alone." And, on the crate count: "I'm confused why the app only code needs more than one crate though." Nine cargo workspaces become three, and the port's project code -- which sat in five places, four of them inside the framework -- becomes one crate, `ai-app`, in `app-rust/`: client-core -> app-rust/src/client iris/transcript-ui -> app-rust/src/ui iris/transcript-fixture -> app-rust/src/ui/fixture.rs + tests/ + touch/ iris/desktop-app -> app-rust/src/desktop + src/bin_desktop.rs iris/android-app -> app-rust/src/android + android-project/ android-shell -> app-rust/src/shell iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now mentions no session, transcript, setup or server anywhere. Only two of the old splits had a reason that survived reading. event-model stays a crate at the repo root because server/ depends on it too, so a crate is what makes the backend and the app agree by construction. The two Android .so names looked like a hard constraint -- a package produces one library artifact -- until P2 turned out to already plan merging those two Android apps into one; both faces now come out of libai_app.so, picked apart by features so `--no-default-features --features shell` keeps wgpu, parley and iris out of the Compose app's APK. docs/RUST.md's "One app crate" has the rest, including what each remaining feature is for. DECISIONS.md and SUBAGENTS.md move into docs/ with everything else. Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so, build-apk.sh produces an APK that installs and launches on this checkout's emulator (Gl ... virgl, as expected), and the phone-sized headless screenshot renders the transcript unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7b54aaf3c4
commit
a9312e9431
113 files changed
+23221
-2992
No files matched your search
@@ -0,0 +1,129 @@
|
||||
//! Pass conditions for RUST.md's I4, exercised the same way
|
||||
//! `layout_tests.rs` exercises LAYOUT.md's: `AccessTree` only touches
|
||||
//! `Widgets`/`UiRenderState`, neither of which needs a GPU or a window, so
|
||||
//! it can be driven directly against `layout_tests::TestRsc`.
|
||||
|
||||
use crate::layout_tests::TestRsc;
|
||||
use crate::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn a_named_widget_reaches_the_tree_with_its_role_and_bounds() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let leaf: WeakWidget<Rect> = rect(UiColor::WHITE).label("Add task").add(&mut rsc);
|
||||
let root = leaf.upgrade(&mut rsc).any();
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((800.0, 600.0));
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let mut access = AccessTree::new();
|
||||
let update = access
|
||||
.update(rsc.widgets(), &render, &rsc)
|
||||
.expect("a first draw with a named widget must produce a tree");
|
||||
|
||||
// One node for the widget, one for the synthetic window root.
|
||||
assert_eq!(update.nodes.len(), 2);
|
||||
let (_, node) = update
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|(_, n)| n.role() != accesskit::Role::Window)
|
||||
.expect("the named widget's own node");
|
||||
assert_eq!(node.label(), Some("Add task"));
|
||||
assert_eq!(node.role(), accesskit::Role::Unknown);
|
||||
let bounds = node.bounds().expect("a drawn widget reports its bounds");
|
||||
let region = render
|
||||
.window_region(&leaf, &rsc)
|
||||
.expect("the widget is active after render.update");
|
||||
assert_eq!(bounds.x0, region.top_left.x as f64);
|
||||
assert_eq!(bounds.y0, region.top_left.y as f64);
|
||||
assert_eq!(bounds.x1, region.bot_right.x as f64);
|
||||
assert_eq!(bounds.y1, region.bot_right.y as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_widget_with_no_label_never_reaches_the_tree() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let root = rsc.ui.widgets.add_strong(rect(UiColor::WHITE));
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((800.0, 600.0));
|
||||
render.update(&root.any(), &mut rsc);
|
||||
|
||||
let mut access = AccessTree::new();
|
||||
assert!(
|
||||
access.update(rsc.widgets(), &render, &rsc).is_none(),
|
||||
"no widget was ever `.label()`ed, so there is nothing to report -- \
|
||||
not even an empty tree change"
|
||||
);
|
||||
}
|
||||
|
||||
/// LAYOUT.md's "a moved subtree" lesson applies here too: `resolved_region`
|
||||
/// (which `window_region` sits on) walks the move-offset chain, so a
|
||||
/// widget moved via `Offset` -- not redrawn from scratch -- must still
|
||||
/// report where it actually ended up.
|
||||
#[test]
|
||||
fn bounds_follow_a_moved_widget_and_updates_stay_incremental() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let leaf: WeakWidget<Rect> = rect(UiColor::WHITE).label("thing").add(&mut rsc);
|
||||
let leaf_strong = leaf.upgrade(&mut rsc).any();
|
||||
let offset = rsc.ui.widgets.add_strong(Offset {
|
||||
inner: leaf_strong,
|
||||
amt: UiVec2::ZERO,
|
||||
});
|
||||
let offset_id = offset.weak();
|
||||
let root = offset.any();
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((800.0, 600.0));
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let mut access = AccessTree::new();
|
||||
access
|
||||
.update(rsc.widgets(), &render, &rsc)
|
||||
.expect("the first draw is always a change");
|
||||
assert_eq!(access.take_rebuilds(), 1);
|
||||
|
||||
// Unchanged frame: nothing moved, nothing renamed -- `update` must
|
||||
// report no change, and the rebuild counter (I4's twin of
|
||||
// `take_counters`) must stay at 0.
|
||||
render.update(&root, &mut rsc);
|
||||
assert!(access.update(rsc.widgets(), &render, &rsc).is_none());
|
||||
assert_eq!(access.take_rebuilds(), 0);
|
||||
|
||||
// Move the child via `Offset` (a move-offset write, not necessarily a
|
||||
// full redraw of the leaf -- see `resolve_move_chain`) and confirm the
|
||||
// reported bounds shifted by exactly that amount, in exactly one more
|
||||
// rebuild.
|
||||
let before = render
|
||||
.window_region(&leaf, &rsc)
|
||||
.expect("active before the move");
|
||||
rsc.ui.widgets.get_mut(&offset_id).unwrap().amt = UiVec2::abs(Vec2::new(50.0, 0.0));
|
||||
render.update(&root, &mut rsc);
|
||||
let update = access
|
||||
.update(rsc.widgets(), &render, &rsc)
|
||||
.expect("a moved named widget is a change");
|
||||
assert_eq!(access.take_rebuilds(), 1);
|
||||
|
||||
let after = render
|
||||
.window_region(&leaf, &rsc)
|
||||
.expect("still active after the move");
|
||||
// Not asserting the exact delta: `Offset`'s own `amt` -> pixel mapping
|
||||
// is that widget's business, not this tree's. What I4 owns is that
|
||||
// `AccessTree` reports whatever `window_region` says *now* -- so the
|
||||
// node must have moved, and in the direction the offset moved it.
|
||||
assert!(
|
||||
after.top_left.x > before.top_left.x,
|
||||
"the leaf's reported bounds must move right along with its offset"
|
||||
);
|
||||
|
||||
let (_, node) = update
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|(_, n)| n.role() != accesskit::Role::Window)
|
||||
.unwrap();
|
||||
let bounds = node.bounds().unwrap();
|
||||
assert_eq!(bounds.x0, after.top_left.x as f64);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! I4 (RUST.md): the Android half of the AccessKit push, over
|
||||
//! `accesskit_android::Adapter` and android-view's
|
||||
//! `AccessibilityNodeProvider`. Carries E1's mitigation for the adapter's
|
||||
//! reproducible abort: `accesskit_android`'s `State` (0.4.0 and 0.8.0
|
||||
//! alike) never moves back to `Inactive` once a client attaches, so once
|
||||
//! one has, every later `QueuedEvents::raise` reaches
|
||||
//! `AccessibilityManager.sendAccessibilityEvent` -- which throws if
|
||||
//! accessibility has since been switched off (or the client detached),
|
||||
//! and android-view's `panic = "abort"` turns that Java exception into a
|
||||
//! process kill. `raise_if_enabled` is the gate: ask
|
||||
//! `AccessibilityManager.isEnabled()` immediately before every `raise`
|
||||
//! and drop the events instead of calling it when the answer is no. See
|
||||
//! RUST.md's E1 box for the full repro.
|
||||
use accesskit::{ActionHandler, ActionRequest, ActivationHandler, TreeUpdate};
|
||||
use accesskit_android::QueuedEvents;
|
||||
use android_view::{
|
||||
View,
|
||||
jni::{JNIEnv, objects::JObject},
|
||||
};
|
||||
use iris_core::{AccessTree, UiRenderState, UiRsc, Widgets};
|
||||
|
||||
/// The `ActivationHandler` `accesskit_android::Adapter` asks for its
|
||||
/// initial tree from -- unlike `accesskit_winit`'s handlers (see
|
||||
/// `default/access.rs`), this one is only ever invoked synchronously from
|
||||
/// inside a JNI callback that already holds everything it needs, so it can
|
||||
/// just borrow `IrisViewPeer`'s own fields for the length of one call
|
||||
/// rather than going through a channel.
|
||||
pub(super) struct AndroidAccessSource<'a> {
|
||||
pub widgets: &'a Widgets,
|
||||
pub render: &'a UiRenderState,
|
||||
pub rsc: &'a dyn UiRsc,
|
||||
}
|
||||
|
||||
impl ActivationHandler for AndroidAccessSource<'_> {
|
||||
fn request_initial_tree(&mut self) -> Option<TreeUpdate> {
|
||||
Some(AccessTree::build_full(self.widgets, self.render, self.rsc))
|
||||
}
|
||||
}
|
||||
|
||||
/// Every AccessKit action request is inert here -- see this module's doc
|
||||
/// comment and `default/access.rs`'s matching handler for why: a screen
|
||||
/// reader's tap on a named node is a real touch delivered at that node's
|
||||
/// bounds, which the ordinary pointer path already handles once the
|
||||
/// bounds `AccessTree` reports are right.
|
||||
pub(super) struct NullActionHandler;
|
||||
impl ActionHandler for NullActionHandler {
|
||||
fn do_action(&mut self, _request: ActionRequest) {}
|
||||
}
|
||||
|
||||
fn is_accessibility_enabled<'local>(env: &mut JNIEnv<'local>, view: &View<'local>) -> bool {
|
||||
let context = view.context(env);
|
||||
let name = env.new_string("accessibility").unwrap();
|
||||
let manager: JObject = env
|
||||
.call_method(
|
||||
&context.0,
|
||||
"getSystemService",
|
||||
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||
&[(&name).into()],
|
||||
)
|
||||
.unwrap()
|
||||
.l()
|
||||
.unwrap();
|
||||
if manager.is_null() {
|
||||
return false;
|
||||
}
|
||||
env.call_method(&manager, "isEnabled", "()Z", &[])
|
||||
.unwrap()
|
||||
.z()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The one place `QueuedEvents::raise` may be called -- see this module's
|
||||
/// doc comment. Every call site pushes this as a deferred callback rather
|
||||
/// than calling it inline, matching android-view's own demo: `raise`
|
||||
/// itself asks not to be called while the caller holds locks a framework
|
||||
/// callback might, and a deferred callback runs after the current one has
|
||||
/// returned them.
|
||||
pub(super) fn raise_if_enabled<'local>(
|
||||
env: &mut JNIEnv<'local>,
|
||||
view: &View<'local>,
|
||||
events: QueuedEvents,
|
||||
) {
|
||||
if is_accessibility_enabled(env, view) {
|
||||
events.raise(env, &view.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use crate::attr::{FocusHost, recent_click};
|
||||
use crate::prelude::*;
|
||||
|
||||
use super::view::HasAndroidUiState;
|
||||
|
||||
impl<T: HasAndroidUiState> 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<WeakWidget<TextEdit>>) {
|
||||
self.android_state_mut().focus = id;
|
||||
}
|
||||
|
||||
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||
self.android_state().focus == Some(id)
|
||||
}
|
||||
|
||||
fn focus_gained(&mut self, region: Option<PixelRegion>) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! `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<State: AndroidAppState> IrisViewPeer<State> {
|
||||
fn focus(&self) -> Option<WeakWidget<TextEdit>> {
|
||||
self.state.android_state().focus
|
||||
}
|
||||
|
||||
/// Tell Gboard where the caret/selection and the composing region
|
||||
/// actually are, via `InputMethodManager.updateSelection` -- every one
|
||||
/// of android-view's own demo's `set_composing_text_internal`/`render`
|
||||
/// calls this, and this bridge never did, which is what left Gboard's
|
||||
/// own model of the field diverging from `TextEdit`'s real one after
|
||||
/// the very first edit (RUST.md's P0 box, "doesn't enter it until I
|
||||
/// hit space, and also doesn't move cursor forward" -- Gboard holds
|
||||
/// its composing keystrokes back until it believes the app has caught
|
||||
/// up, and without this call it never does). Called from
|
||||
/// [`IrisViewPeer::after_input`], the one tail every touch/key/IME
|
||||
/// callback already runs through, rather than duplicated at each of
|
||||
/// this file's mutating methods.
|
||||
///
|
||||
/// `candidates_start`/`candidates_end` report the composing region;
|
||||
/// `-1, -1` when nothing is composing, matching `EditorInfo`'s own
|
||||
/// convention. `compose_len` is tracked in `char`s (this module's doc
|
||||
/// comment), so this reports it as that many UTF-16 units back from the
|
||||
/// caret -- exact for the common BMP case, the same approximation
|
||||
/// `set_composing_text` already makes.
|
||||
pub(super) fn update_ime_selection(&mut self, ctx: &mut CallbackCtx) {
|
||||
let Some(focus) = self.focus() else { return };
|
||||
let text = &self.rsc[focus];
|
||||
let Some(sel) = text.selection_range() else {
|
||||
return;
|
||||
};
|
||||
let content = text.text();
|
||||
let sel_start = byte_to_utf16(content, sel.start) as i32;
|
||||
let sel_end = byte_to_utf16(content, sel.end) as i32;
|
||||
let compose_len = self.state.android_state().compose_len;
|
||||
let (comp_start, comp_end) = if compose_len > 0 {
|
||||
let caret = byte_to_utf16(content, text.caret().unwrap_or(sel.end)) as i32;
|
||||
(caret - compose_len as i32, caret)
|
||||
} else {
|
||||
(-1, -1)
|
||||
};
|
||||
let imm = ctx.view.input_method_manager(&mut ctx.env);
|
||||
imm.update_selection(
|
||||
&mut ctx.env,
|
||||
&ctx.view,
|
||||
sel_start,
|
||||
sel_end,
|
||||
comp_start,
|
||||
comp_end,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
|
||||
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<Cow<'slf, str>> {
|
||||
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<Cow<'slf, str>> {
|
||||
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<Cow<'slf, str>> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! 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<RefCell<Shared>>` 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 (`WindowInsets.Type.ime()`), in physical
|
||||
/// pixels, 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,
|
||||
/// `WindowInsets.isVisible(ime())` -- whether the keyboard is up, which
|
||||
/// is **not** the same question as `ime_bottom > 0` and is why the two
|
||||
/// are carried separately. They disagree for the frames the keyboard
|
||||
/// spends sliding: visible, with a height still on its way to the full
|
||||
/// one. Anything asking "make how much room" reads `ime_bottom`;
|
||||
/// anything asking "is the keyboard up" reads this. See
|
||||
/// `MainActivity.java`'s comment for the history -- the height used to
|
||||
/// be sent *as* this boolean, which is what left the composer padded by
|
||||
/// one pixel on Iris's phone.
|
||||
pub ime_visible: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Shared {
|
||||
pub insets: Insets,
|
||||
/// How many times Java has called `applyWindowInsetsNative` for this
|
||||
/// peer, whether or not the numbers changed. Deliberately **not** a
|
||||
/// field of `Insets`, which is compared for equality each frame to
|
||||
/// decide whether to re-run `on_insets_changed`; a counter in there
|
||||
/// would make every dispatch look like a change.
|
||||
///
|
||||
/// It exists because "the keyboard does not push anything up" has two
|
||||
/// completely different causes that look identical on screen -- the
|
||||
/// listener never fired, or it fired with a zero `ime_bottom` -- and
|
||||
/// Iris has no logcat on her phone (docs/IRIS_TODO.md). This number is
|
||||
/// in the `Diagnostics` overlay, so one screenshot separates them.
|
||||
pub updates: u64,
|
||||
}
|
||||
|
||||
type SharedMap = HashMap<jlong, SendWrapper<Rc<RefCell<Shared>>>>;
|
||||
|
||||
fn map() -> &'static Mutex<SharedMap> {
|
||||
static MAP: OnceLock<Mutex<SharedMap>> = 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<RefCell<Shared>>) {
|
||||
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,
|
||||
ime_visible: jint,
|
||||
) {
|
||||
if let Some(shared) = map().lock().unwrap().get(&peer) {
|
||||
let mut shared = shared.borrow_mut();
|
||||
shared.insets = Insets {
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
ime_bottom,
|
||||
ime_visible: ime_visible != 0,
|
||||
};
|
||||
shared.updates += 1;
|
||||
}
|
||||
// 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: "(JIIIIII)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();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! 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 access;
|
||||
mod attr;
|
||||
mod ime;
|
||||
mod input;
|
||||
mod insets;
|
||||
mod platform;
|
||||
mod render;
|
||||
mod view;
|
||||
|
||||
pub use insets::Insets;
|
||||
pub use render::AndroidRenderer;
|
||||
pub use view::{
|
||||
AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState, IrisViewPeer, WindowInsets,
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use crate::platform::OpenUrl;
|
||||
use android_view::{
|
||||
View,
|
||||
jni::{JNIEnv, objects::JValue},
|
||||
};
|
||||
|
||||
use super::view::HasAndroidUiState;
|
||||
|
||||
/// Android's URL opener. Like `FocusHost::focus_gained`'s keyboard, the
|
||||
/// real work is a JNI call and this runs deep inside the sensor dispatch
|
||||
/// with no `CallbackCtx` in reach -- so it raises a flag that
|
||||
/// `IrisViewPeer::after_input` consumes, exactly as
|
||||
/// `pending_show_keyboard` does.
|
||||
///
|
||||
/// Last request wins: two links cannot be tapped in one frame, and a URL
|
||||
/// left queued from a frame that somehow never reached `after_input`
|
||||
/// would open at some unrelated later tap, which is worse than dropping
|
||||
/// it.
|
||||
impl<T: HasAndroidUiState> OpenUrl for T {
|
||||
fn open_url(&mut self, url: &str) {
|
||||
self.android_state_mut().pending_open_url = Some(url.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the view's
|
||||
/// own context.
|
||||
///
|
||||
/// `FLAG_ACTIVITY_NEW_TASK` because the context here is the view's, which
|
||||
/// may be an application context rather than the activity's -- Android
|
||||
/// throws `AndroidRuntimeException` for a non-activity context without it,
|
||||
/// and it is harmless when the context *is* an activity's.
|
||||
///
|
||||
/// Every failure is logged with the URL and returns; there is nothing to
|
||||
/// fall back to, and the reader will see that nothing happened.
|
||||
pub(super) fn open_url<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, url: &str) {
|
||||
match try_open_url(env, view, url) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
// A pending Java exception makes every later JNI call fail in
|
||||
// ways nowhere near here, so it is cleared at the boundary.
|
||||
let _ = env.exception_clear();
|
||||
log::warn!("could not open {url}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_open_url<'local>(
|
||||
env: &mut JNIEnv<'local>,
|
||||
view: &View<'local>,
|
||||
url: &str,
|
||||
) -> Result<(), android_view::jni::errors::Error> {
|
||||
let context = env
|
||||
.call_method(&view.0, "getContext", "()Landroid/content/Context;", &[])?
|
||||
.l()?;
|
||||
let jurl = env.new_string(url)?;
|
||||
let uri = env.call_static_method(
|
||||
"android/net/Uri",
|
||||
"parse",
|
||||
"(Ljava/lang/String;)Landroid/net/Uri;",
|
||||
&[JValue::Object(jurl.as_ref())],
|
||||
)?;
|
||||
let action = env.new_string("android.intent.action.VIEW")?;
|
||||
let intent = env.new_object(
|
||||
"android/content/Intent",
|
||||
"(Ljava/lang/String;Landroid/net/Uri;)V",
|
||||
&[JValue::Object(action.as_ref()), JValue::Object(&uri.l()?)],
|
||||
)?;
|
||||
env.call_method(
|
||||
&intent,
|
||||
"addFlags",
|
||||
"(I)Landroid/content/Intent;",
|
||||
&[JValue::Int(FLAG_ACTIVITY_NEW_TASK)],
|
||||
)?;
|
||||
env.call_method(
|
||||
&context,
|
||||
"startActivity",
|
||||
"(Landroid/content/Intent;)V",
|
||||
&[JValue::Object(&intent)],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `android.content.Intent.FLAG_ACTIVITY_NEW_TASK`. A constant rather than
|
||||
/// a static-field read: it is part of the platform's stable ABI and
|
||||
/// reading it costs two more JNI calls that can each fail.
|
||||
const FLAG_ACTIVITY_NEW_TASK: i32 = 0x1000_0000;
|
||||
@@ -0,0 +1,526 @@
|
||||
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 std::time::{Duration, Instant};
|
||||
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<DisplayHandle<'_>, HandleError> {
|
||||
Ok(DisplayHandle::android())
|
||||
}
|
||||
}
|
||||
|
||||
impl HasWindowHandle for AndroidWindowHandle {
|
||||
fn window_handle(&self) -> Result<WindowHandle<'_>, 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<AndroidRenderer>`, `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,
|
||||
/// The adapter identity, kept past `new()` for the Diagnostics page --
|
||||
/// `Adapter` itself is not `Clone`, so the three fields the page shows
|
||||
/// are copied out once here rather than holding the adapter.
|
||||
pub adapter_name: String,
|
||||
pub adapter_backend: Backend,
|
||||
pub adapter_driver: String,
|
||||
/// Every uncaptured wgpu error since this renderer was created -- see
|
||||
/// `iris_core::WgpuErrorLog`'s doc comment. Installed on `device` in
|
||||
/// `new()`, kept here so the Diagnostics page and the per-frame log in
|
||||
/// `update()` can both read it without a global.
|
||||
pub wgpu_errors: iris_core::WgpuErrorLog,
|
||||
/// Frames drawn on this surface -- what gates the first-10-frames log
|
||||
/// `update()` writes (RUST.md's P0 box, "the first input frame"
|
||||
/// investigation): a fresh surface is exactly what Iris's own report
|
||||
/// says renders correctly at first, so the frames that matter are the
|
||||
/// first several after each `surface_changed`, not an arbitrary window
|
||||
/// during a long-running session.
|
||||
frame_count: u64,
|
||||
/// Physical pixels per dp -- see `android::view::AndroidUiState::
|
||||
/// content_scale`'s field comment for what this feeds.
|
||||
content_scale: f32,
|
||||
}
|
||||
|
||||
/// One frame's worth of the counters `render/mod.rs`'s doc comments on
|
||||
/// `FrameUpdateStats`/`take_image_bind_group_creates`/
|
||||
/// `take_atlas_pages_grown` describe -- assembled here because the three
|
||||
/// live on two different calling conventions (`FrameUpdateStats` from this
|
||||
/// exact `update()` call; the other two describe the *previous* frame,
|
||||
/// same as `bench_images`' existing use of them) and a diagnostic reader
|
||||
/// should not have to know that split.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct FrameDiagnostics {
|
||||
pub masks_resized: bool,
|
||||
pub moves_resized: bool,
|
||||
/// From the previous frame's `update()` -- see the struct doc.
|
||||
pub atlas_pages_grown_prev: u64,
|
||||
pub image_bind_group_creates_prev: u64,
|
||||
}
|
||||
|
||||
impl AndroidRenderer {
|
||||
/// `Err` holds a full, human-readable report for **every** way this
|
||||
/// can fail -- no surface, no adapter, no device, or wgpu's own error
|
||||
/// text (`UiRenderNode::new`'s doc comment) plus the adapter identity
|
||||
/// and the limits/downlevel flags bind-group-layout validation checks
|
||||
/// against -- rather than the panic wgpu's default error handler would
|
||||
/// otherwise raise with no caller able to see it. This is what aborted
|
||||
/// the P0 bench APK on Iris's phone with only "wgpu error: Validation
|
||||
/// Error" surviving into the crash report (RUST.md's P0 box, "iris
|
||||
/// bench crash on the phone, 2026-09-06"): `create_bind_group_layout`
|
||||
/// validates against *this* adapter's downlevel capabilities and
|
||||
/// limits, which a desktop GPU and the emulator's software renderers
|
||||
/// never exercised. The caller (`android::view::IrisViewPeer::
|
||||
/// surface_changed`) logs this one-line-flattened and shows it on
|
||||
/// screen instead of aborting the process.
|
||||
pub fn new(
|
||||
window: NativeWindow,
|
||||
width: u32,
|
||||
height: u32,
|
||||
content_scale: f32,
|
||||
) -> Result<Self, String> {
|
||||
// `force-gles` (RUST.md's I5 "Where iris's frame time goes") pins
|
||||
// the build to GLES, to isolate whether the backend itself explains
|
||||
// the frame time gap against Compose. `cfg!` rather than a runtime
|
||||
// switch: there is no way to hand an env var to an already-launched
|
||||
// Android process on this machine (see the feature's doc in
|
||||
// Cargo.toml).
|
||||
//
|
||||
// Otherwise: **Vulkan where it has an adapter at all, GLES where it
|
||||
// has none.** `Backends::PRIMARY` leaves `GL` out, so a device
|
||||
// offering only a GLES adapter had no adapter at all and this
|
||||
// function aborted the process -- this checkout's emulator, whose
|
||||
// Vulkan ICD carries no adapter behind it (`NotFound {
|
||||
// active_backends: VULKAN, no_adapter_backends: VULKAN,
|
||||
// supported_backends: VULKAN | GL }`), and the crash loop in
|
||||
// RUST.md's queue.
|
||||
//
|
||||
// The choice is made *before any surface exists*, with an instance
|
||||
// that never touches the window, because **an Android window can be
|
||||
// connected to one graphics API only**. One instance carrying both
|
||||
// backends does not work: `create_surface` builds a raw surface per
|
||||
// backend, Vulkan's `vkCreateAndroidSurfaceKHR` claims the window
|
||||
// first, and the GLES surface made from the same window then fails
|
||||
// `configure` as lost -- measured here as "In Surface::configure /
|
||||
// Invalid surface" followed by an abort in
|
||||
// `Surface::get_current_texture_view`, "Surface is not configured
|
||||
// for presentation".
|
||||
let mut backends = if cfg!(feature = "force-gles") {
|
||||
Backends::GL
|
||||
} else {
|
||||
Backends::PRIMARY
|
||||
};
|
||||
// No display handle: an Android surface is built from the
|
||||
// `NativeWindow` below, and there is no platform connection to hand
|
||||
// wgpu here the way there is on Wayland.
|
||||
let mut instance = Instance::new(InstanceDescriptor {
|
||||
backends,
|
||||
..InstanceDescriptor::new_without_display_handle()
|
||||
});
|
||||
// A build already pinned to GLES has nowhere to fall back to.
|
||||
if backends != Backends::GL && instance.enumerate_adapters(backends).block_on().is_empty() {
|
||||
log::warn!(
|
||||
"iris renderer: no {backends:?} adapter on this device, falling back to GLES"
|
||||
);
|
||||
backends = Backends::GL;
|
||||
instance = Instance::new(InstanceDescriptor {
|
||||
backends,
|
||||
..InstanceDescriptor::new_without_display_handle()
|
||||
});
|
||||
}
|
||||
|
||||
// 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 }))
|
||||
.map_err(|error| format!("Could not create the android surface: {error}"))?;
|
||||
|
||||
// Every step from here to a live device reports rather than
|
||||
// panics, for the one reason: on the phone these builds run on
|
||||
// there is no `adb`, so an abort's message reaches a tombstone
|
||||
// nobody can read and the launcher simply restarts the app --
|
||||
// which is what a crash loop with no explanation is. The caller
|
||||
// (`android::view::IrisViewPeer::surface_changed`) puts this
|
||||
// string on screen and in the app's own log ring instead.
|
||||
let adapter = instance
|
||||
.request_adapter(&RequestAdapterOptions {
|
||||
power_preference: PowerPreference::default(),
|
||||
compatible_surface: Some(&surface),
|
||||
force_fallback_adapter: false,
|
||||
..Default::default()
|
||||
})
|
||||
.block_on()
|
||||
.map_err(|error| format!("No usable GPU adapter for backends {backends:?}: {error}"))?;
|
||||
|
||||
// Same request as the winit backend's `UiRenderer::new` -- no
|
||||
// binding-array features, see TEXTURES.md's "Recommended shape".
|
||||
// `iris_core::device_limits()` is shared between the two backends;
|
||||
// see its own doc for why it is not simply `Limits::default()`.
|
||||
let (device, queue) = adapter
|
||||
.request_device(&DeviceDescriptor {
|
||||
required_limits: iris_core::device_limits(),
|
||||
..Default::default()
|
||||
})
|
||||
.block_on()
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"The adapter {} ({:?}) refused a device: {error}",
|
||||
adapter.get_info().name,
|
||||
adapter.get_info().backend,
|
||||
)
|
||||
})?;
|
||||
|
||||
// wgpu's default handler for an error raised outside `UiRenderNode::
|
||||
// new`'s own error scopes (i.e. everything past device creation --
|
||||
// an ordinary frame's `update`/`draw`) is `panic!`, unconditionally,
|
||||
// with no caller able to intervene: the same mechanism that aborted
|
||||
// the P0 bench APK once already, just at a different call site. Log
|
||||
// and record instead of letting that default stand -- RUST.md's P0
|
||||
// box, "every wgpu uncaptured error ... it must never panic in
|
||||
// release".
|
||||
let wgpu_errors = iris_core::WgpuErrorLog::default();
|
||||
let wgpu_errors_for_handler = wgpu_errors.clone();
|
||||
device.on_uncaptured_error(std::sync::Arc::new(move |error| {
|
||||
log::error!("iris wgpu uncaptured error: {error}");
|
||||
wgpu_errors_for_handler.record(error);
|
||||
}));
|
||||
|
||||
let info = adapter.get_info();
|
||||
let adapter_name = info.name.clone();
|
||||
let adapter_backend = info.backend;
|
||||
// Either half can be empty -- the emulator's GLES adapter reports
|
||||
// no `driver` and a long `driver_info`, so joining unconditionally
|
||||
// left a leading space in every log line it appears in.
|
||||
let adapter_driver = [info.driver.as_str(), info.driver_info.as_str()]
|
||||
.into_iter()
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
// Say which adapter won, in the same words `default::render` uses,
|
||||
// and at startup rather than only on the Diagnostics page: the
|
||||
// backend alone (logged by `view.rs` when a renderer is built) does
|
||||
// not separate the cases that matter. In this checkout's emulator
|
||||
// `Gl` is the host's real GPU through virgl, and `Gl` under
|
||||
// `EMU_GPU=software` is SwiftShader on the CPU; on a phone `Vulkan`
|
||||
// is the device's own driver. A frame time or a screenshot with no
|
||||
// record of which of those produced it cannot be read, and the
|
||||
// fallback above is silent by design.
|
||||
log::info!(
|
||||
"iris renderer: {adapter_name} ({adapter_backend:?}, {adapter_driver}) on \
|
||||
{backends:?}"
|
||||
);
|
||||
|
||||
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,
|
||||
// wgpu 30's new field; `Auto` is what every earlier version did.
|
||||
color_space: SurfaceColorSpace::Auto,
|
||||
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);
|
||||
// Physical pixels, matching the swapchain's own `width`/`height`
|
||||
// exactly -- see `android::view::AndroidUiState::content_scale`'s
|
||||
// field comment for why this is no longer divided into a separate
|
||||
// logical space (that stopgap is what made text blurry, RUST.md's
|
||||
// P0 box). `Len::dp` folds the density in at layout time instead,
|
||||
// so nothing here needs to know it at all.
|
||||
let window_size = iris_core::util::Vec2::new(width as f32, height as f32);
|
||||
let ui = match UiRenderNode::new(&device, &queue, &config, window_size) {
|
||||
Ok(ui) => ui,
|
||||
Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
surface,
|
||||
device,
|
||||
queue,
|
||||
config,
|
||||
encoder,
|
||||
ui,
|
||||
adapter_name,
|
||||
adapter_backend,
|
||||
adapter_driver,
|
||||
wgpu_errors,
|
||||
frame_count: 0,
|
||||
content_scale,
|
||||
})
|
||||
}
|
||||
|
||||
/// The adapter identity plus every limit and downlevel flag
|
||||
/// `create_bind_group_layout` validates a storage buffer or texture
|
||||
/// binding against, followed by wgpu's own error text -- everything a
|
||||
/// person reading this off a screenshot needs to tell "this adapter
|
||||
/// lacks X" from "this is a bug in the layout." Named explicitly rather
|
||||
/// than `{limits:?}`/`{flags:?}` wholesale, because `Limits` alone is
|
||||
/// dozens of fields nobody asked for -- these are exactly the ones
|
||||
/// `UiRenderNode::new`'s layouts (`rsc_layout`, `masks_layout`,
|
||||
/// `primitive_layout`) can fail against, per `CreateBindGroupLayoutError`
|
||||
/// (`wgpu-core::binding_model`) and its downlevel-flag checks
|
||||
/// (`wgpu-core::device::resource`, `VERTEX_STORAGE` in particular --
|
||||
/// the one storage buffer here, `move_offsets`, that is visible to the
|
||||
/// vertex stage).
|
||||
fn diagnostic(adapter: &Adapter, wgpu_error: &str) -> String {
|
||||
let info = adapter.get_info();
|
||||
let limits = adapter.limits();
|
||||
let downlevel = adapter.get_downlevel_capabilities();
|
||||
format!(
|
||||
"iris could not start rendering. Copy this text and send it to Iris.\n\n\
|
||||
adapter: {name} ({backend:?}), driver: {driver} {driver_info}\n\
|
||||
limits: max_storage_buffers_per_shader_stage={max_storage_buffers} \
|
||||
max_sampled_textures_per_shader_stage={max_sampled_textures} \
|
||||
max_bind_groups={max_bind_groups} \
|
||||
max_bindings_per_bind_group={max_bindings} \
|
||||
max_storage_buffer_binding_size={max_storage_binding} \
|
||||
min_storage_buffer_offset_alignment={min_storage_align}\n\
|
||||
downlevel flags: {flags:?}\n\n\
|
||||
{wgpu_error}",
|
||||
name = info.name,
|
||||
backend = info.backend,
|
||||
driver = info.driver,
|
||||
driver_info = info.driver_info,
|
||||
max_storage_buffers = limits.max_storage_buffers_per_shader_stage,
|
||||
max_sampled_textures = limits.max_sampled_textures_per_shader_stage,
|
||||
max_bind_groups = limits.max_bind_groups,
|
||||
max_bindings = limits.max_bindings_per_bind_group,
|
||||
max_storage_binding = limits.max_storage_buffer_binding_size,
|
||||
min_storage_align = limits.min_storage_buffer_offset_alignment,
|
||||
flags = downlevel.flags,
|
||||
)
|
||||
}
|
||||
|
||||
/// The Diagnostics page's whole report: adapter identity, font
|
||||
/// resolution, the atlas's own view count, every uncaptured wgpu error
|
||||
/// so far, and the frame report -- RUST.md's P0 box, "a named
|
||||
/// `Diagnostics` control ... adapter info, limits, fonts found, atlas
|
||||
/// format/pages, wgpu errors so far, frame report". One string rather
|
||||
/// than a struct the caller formats, since the only consumer is a
|
||||
/// plain `TextView` with a "copy this and send it to Iris" affordance,
|
||||
/// the same shape `surface_changed`'s crash report already uses
|
||||
/// (UI_RULES.md: a failure -- or here, a state worth reporting --
|
||||
/// carries enough to act on where it's shown).
|
||||
pub fn diagnostics_report(
|
||||
&self,
|
||||
font: &iris_core::FontDiagnostics,
|
||||
frame_report: &str,
|
||||
) -> String {
|
||||
let errors = self.wgpu_errors.snapshot();
|
||||
let errors_text = if errors.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
errors.join("\n ")
|
||||
};
|
||||
format!(
|
||||
"iris diagnostics. Copy this text and send it to Iris.\n\n\
|
||||
adapter: {name} ({backend:?}), driver: {driver}\n\
|
||||
content_scale: {content_scale}\n\
|
||||
atlas format: Rgba8Unorm, views live: {views}\n\
|
||||
fonts: {families_found} families found, default={default_family:?} \
|
||||
mono={default_mono_family:?}\n\
|
||||
fonts resolved: regular={regular:?} bold={bold:?} italic={italic:?} \
|
||||
mono={mono:?}\n\
|
||||
icon font: {icons:?}\n\
|
||||
wgpu errors since surface creation:\n {errors_text}\n\n\
|
||||
{frame_report}",
|
||||
name = self.adapter_name,
|
||||
backend = self.adapter_backend,
|
||||
driver = self.adapter_driver,
|
||||
content_scale = self.content_scale,
|
||||
views = self.ui.view_count(),
|
||||
families_found = font.families_found,
|
||||
default_family = font.default_family,
|
||||
default_mono_family = font.default_mono_family,
|
||||
regular = font.regular_resolved,
|
||||
bold = font.bold_resolved,
|
||||
italic = font.italic_resolved,
|
||||
mono = font.mono_resolved,
|
||||
icons = font.icon_family,
|
||||
)
|
||||
}
|
||||
|
||||
fn create_encoder(device: &Device) -> CommandEncoder {
|
||||
device.create_command_encoder(&CommandEncoderDescriptor {
|
||||
label: Some("Render Encoder"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns what changed this frame -- see `FrameDiagnostics`'s doc
|
||||
/// comment for why two of its four fields describe the *previous*
|
||||
/// frame rather than this one. `IrisViewPeer::render` logs this for
|
||||
/// the first `DIAGNOSTIC_FRAMES` frames after each `surface_changed`,
|
||||
/// per RUST.md's P0 box ("the first input frame" investigation): the
|
||||
/// glyph-wipe Iris reported happens on the first tap or scroll after a
|
||||
/// fresh surface, so that is exactly the window a report needs to
|
||||
/// cover, not an arbitrary slice of a long session.
|
||||
pub fn update(&mut self, ui: &mut UiData, render: &mut UiRenderState) -> FrameDiagnostics {
|
||||
let atlas_pages_grown_prev = self.ui.take_atlas_pages_grown();
|
||||
let image_bind_group_creates_prev = self.ui.take_image_bind_group_creates();
|
||||
let stats = self.ui.update(&self.device, &self.queue, ui, render);
|
||||
self.frame_count += 1;
|
||||
FrameDiagnostics {
|
||||
masks_resized: stats.masks_resized,
|
||||
moves_resized: stats.moves_resized,
|
||||
atlas_pages_grown_prev,
|
||||
image_bind_group_creates_prev,
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames drawn on this surface so far -- see `frame_count`'s field
|
||||
/// comment.
|
||||
pub fn frame_count(&self) -> u64 {
|
||||
self.frame_count
|
||||
}
|
||||
|
||||
/// Draws and presents one frame, returning the time spent in
|
||||
/// `queue.submit` plus `present()` -- wherever a driver/GPU/compositor
|
||||
/// wait would actually show up. The caller (`android::view::render`)
|
||||
/// already times the whole frame from its own `redraw_to_submit` start;
|
||||
/// subtracting this from that total is `redraw_to_submit` itself
|
||||
/// (layout, text, primitive building, and this method's own render-pass
|
||||
/// recording). RUST.md's I5 "Where iris's frame time goes" diagnosis,
|
||||
/// added 2026-09-05 -- see `iris_core::FrameReport::record_split`'s own
|
||||
/// doc for the caveat this shares: `present()` is not fenced against
|
||||
/// the GPU actually finishing, so this is "how long the CPU was blocked
|
||||
/// handing the frame off", not confirmed GPU time.
|
||||
pub fn draw(&mut self) -> Duration {
|
||||
let output = match self.surface.get_current_texture() {
|
||||
CurrentSurfaceTexture::Success(texture)
|
||||
| CurrentSurfaceTexture::Suboptimal(texture) => texture,
|
||||
// wgpu 30 turned this Result into an enum; every arm here was an
|
||||
// `Err` the previous `.unwrap()` panicked on, except `Occluded`,
|
||||
// which is new.
|
||||
other => panic!("no surface texture to draw into: {other:?}"),
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
let submit_start = Instant::now();
|
||||
self.queue.submit(std::iter::once(encoder.finish()));
|
||||
self.queue.present(output);
|
||||
submit_start.elapsed()
|
||||
}
|
||||
|
||||
/// Physical pixels -- the unit layout and hit-testing use, matching
|
||||
/// the window uniform's own units. See
|
||||
/// `android::view::AndroidUiState::content_scale`'s field comment.
|
||||
pub fn size(&self) -> iris_core::util::Vec2 {
|
||||
iris_core::util::Vec2::new(self.config.width as f32, self.config.height as f32)
|
||||
}
|
||||
|
||||
/// Reconfigures the surface and rewrites the window uniform for a new
|
||||
/// physical size -- deliberately the *only* two things this does.
|
||||
/// `device`, `ui`'s atlas, buffers and bind groups are untouched, so a
|
||||
/// call here (as opposed to a fresh `AndroidRenderer::new`) never
|
||||
/// invalidates a glyph the CPU-side cache already placed in the atlas.
|
||||
/// See `android::view::IrisViewPeer::surface_changed`'s doc comment for
|
||||
/// why that distinction matters -- it is what keeps text on screen
|
||||
/// across an IME resize.
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
self.config.width = width;
|
||||
self.config.height = height;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
let size = iris_core::util::Vec2::new(width as f32, height as f32);
|
||||
self.ui.resize(size, &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. The global ref is what
|
||||
/// survives past the JNI call that handed the `View` to us.
|
||||
///
|
||||
/// **Goes through `View::post_delayed`, not `post_frame_callback`
|
||||
/// directly** -- found the hard way (RUST.md's I5 Android integration):
|
||||
/// `post_frame_callback`'s Java side calls `Choreographer.getInstance()`,
|
||||
/// which throws `IllegalStateException` unless the *calling* thread already
|
||||
/// has a `Looper` (`Choreographer.getInstance()`'s own contract). A tokio
|
||||
/// worker thread, even freshly attached to the JVM, has none -- the crash
|
||||
/// was a `JavaException` inside `View::post_frame_callback`'s `.unwrap()`,
|
||||
/// aborting the process on the second `redraw.request_redraw()` any
|
||||
/// android transcript-screen fetch made. `View.postDelayed(Runnable, 0)`
|
||||
/// is the ordinary Android answer to "queue work onto a View's own UI
|
||||
/// thread from any thread" and needs no Looper of its own; `delayed_callback`
|
||||
/// below is what that Runnable resolves to on the UI thread, where a real
|
||||
/// `post_frame_callback` is safe again.
|
||||
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_delayed(&mut env, 0);
|
||||
}
|
||||
}
|
||||
+1080
File diff suppressed because it is too large.
Load diff
+231
@@ -0,0 +1,231 @@
|
||||
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<WeakWidget<TextEdit>>);
|
||||
/// Called on every tap that should put the IME on `id`: the tap that
|
||||
/// *makes* a `TextEdit` the focus target, and any later tap on one that
|
||||
/// already is. `region` is where it was hit (`None` when the widget
|
||||
/// could not be located, which happens for one it was just deselected
|
||||
/// from). Implementations must be idempotent -- both backends' calls
|
||||
/// (`showSoftInput`, `set_ime_cursor_area`) already are, which is what
|
||||
/// lets the repeat tap be handled by the same call rather than by a
|
||||
/// second "re-show" entry point beside it.
|
||||
fn focus_gained(&mut self, region: Option<PixelRegion>);
|
||||
/// Whether `id` is the current focus target -- what [`select`] uses to
|
||||
/// tell a fresh press (which must wait to see whether it becomes a tap
|
||||
/// or a drag before focusing/showing the IME, Iris 2026-09-06: "if I
|
||||
/// swipe over the input bar it brings up the keyboard") from a drag
|
||||
/// continuing inside a field that was already focused (an ordinary
|
||||
/// drag-to-select, unaffected).
|
||||
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool;
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// `PressStart`/`Pressing`/`PressEnd`, all for the left button -- what
|
||||
/// [`Selector`]/[`Selectable`] register instead of [`CursorSense::
|
||||
/// click_or_drag`], so their shared handler (`on_press`, below) sees every
|
||||
/// frame of a gesture and can tell a completed tap from a drag itself,
|
||||
/// rather than reacting to `PressStart` alone the way `click_or_drag`'s
|
||||
/// consumer used to (Iris, 2026-09-06: "if I swipe over the input bar it
|
||||
/// brings up the keyboard").
|
||||
/// `CursorSense::Cancel` is in the set for the same reason `DragGesture`
|
||||
/// registers it: if a scroll area or a list takes the pointer mid-gesture,
|
||||
/// this field sees no `PressEnd`, and a `press_origin` left set is then
|
||||
/// compared against the *next* press -- a stray selection, or a keyboard
|
||||
/// summoned by a tap somewhere else entirely.
|
||||
fn press_track() -> CursorSenses {
|
||||
CursorSense::click()
|
||||
| CursorSense::Pressing(CursorButton::Left)
|
||||
| CursorSense::unclick()
|
||||
| CursorSense::Cancel
|
||||
}
|
||||
|
||||
pub struct Selector;
|
||||
|
||||
impl<Rsc: HasEvents, W: Widget + 'static> WidgetAttr<Rsc, W> for Selector
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
type Input = WeakWidget<TextEdit>;
|
||||
|
||||
fn run(rsc: &mut Rsc, container: WeakWidget<W>, id: Self::Input) {
|
||||
rsc.register_event(container, press_track(), 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();
|
||||
on_press(
|
||||
rsc,
|
||||
ctx.data.render,
|
||||
ctx.state,
|
||||
id,
|
||||
pos,
|
||||
size,
|
||||
ctx.data.sense,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Selectable;
|
||||
|
||||
impl<Rsc: HasEvents> WidgetAttr<Rsc, TextEdit> for Selectable
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
type Input = ();
|
||||
|
||||
fn run(rsc: &mut Rsc, id: WeakWidget<TextEdit>, _: Self::Input) {
|
||||
rsc.register_event(id, press_track(), move |ctx, rsc| {
|
||||
on_press(
|
||||
rsc,
|
||||
ctx.data.render,
|
||||
ctx.state,
|
||||
id,
|
||||
ctx.data.pos,
|
||||
ctx.data.size,
|
||||
ctx.data.sense,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// One press-track frame (`PressStart`, `Pressing` or `PressEnd`) over a
|
||||
/// selectable field. A field that is *already* focused behaves exactly as
|
||||
/// `click_or_drag` always did -- every frame updates the selection, which
|
||||
/// is what lets a finger already inside a focused field drag out a
|
||||
/// selection. A field that is **not** focused withholds `select`'s
|
||||
/// focus-granting side effects (and so the platform-specific `focus_gained`
|
||||
/// that shows the keyboard) until the press resolves as a tap: `PressEnd`
|
||||
/// with no frame in between having moved past [`DRAG_SLOP`] from where the
|
||||
/// press began. A drag recognised before release simply cancels the
|
||||
/// pending tap and does nothing further here -- it is not consumed, so
|
||||
/// whatever is behind the field (a list to pan) still sees every frame of
|
||||
/// it, the same as a drag that never touched a selectable field at all.
|
||||
fn on_press(
|
||||
rsc: &mut impl UiRsc,
|
||||
render: &UiRenderState,
|
||||
state: &mut impl FocusHost,
|
||||
id: WeakWidget<TextEdit>,
|
||||
pos: Vec2,
|
||||
size: Vec2,
|
||||
sense: CursorSense,
|
||||
) {
|
||||
if state.is_focused(id) {
|
||||
// Already focused, so there is no keyboard to withhold -- but a
|
||||
// vertical drag still is not a selection. Android's own `EditText`
|
||||
// scrolls its overflowed text on a vertical drag and starts a
|
||||
// selection only from a long press; a scroll area wrapping this
|
||||
// field (`ScrollController::drag`) is what actually pans, and it needs the
|
||||
// first frames of the gesture not to have selected anything behind
|
||||
// it before it crosses `DRAG_SLOP` and takes pointer capture.
|
||||
// `press_origin` carries the same meaning here as in the unfocused
|
||||
// branch below -- "this gesture is still eligible", cleared the
|
||||
// moment it becomes a drag -- so there is one flag, not two.
|
||||
match sense {
|
||||
CursorSense::PressStart(_) => {
|
||||
let recent = state.recent_click();
|
||||
id.edit(rsc).text.press_origin = Some(pos);
|
||||
id.edit(rsc).select(pos, size, false, recent);
|
||||
}
|
||||
CursorSense::Pressing(_) | CursorSense::PressEnd(_) => {
|
||||
let mut ctx = id.edit(rsc);
|
||||
let Some(origin) = ctx.text.press_origin else {
|
||||
return;
|
||||
};
|
||||
let (dx, dy) = (pos.x - origin.x, pos.y - origin.y);
|
||||
if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
|
||||
ctx.text.press_origin = None;
|
||||
return;
|
||||
}
|
||||
let ended = matches!(sense, CursorSense::PressEnd(_));
|
||||
if ended {
|
||||
ctx.text.press_origin = None;
|
||||
}
|
||||
ctx.select(pos, size, true, false);
|
||||
// A tap on a field that is *already* focused asks for the
|
||||
// keyboard again (Iris's phone, 2026-09-06: "I can't reopen
|
||||
// keyboard by tapping on message box after it already
|
||||
// happened once"). Dismissing the IME -- back gesture, or
|
||||
// its own hide button -- takes the keyboard away but leaves
|
||||
// the field focused, so without this the one branch that
|
||||
// requests it (the unfocused one below) never runs again
|
||||
// and the field is permanently unable to summon it.
|
||||
// Android's own `EditText` does exactly this: every tap on
|
||||
// a focused field calls `showSoftInput`, which is a no-op
|
||||
// when the keyboard is already up.
|
||||
//
|
||||
// Gated on the same tap-vs-drag test the unfocused branch
|
||||
// uses, not on `PressEnd` alone, so a drag-to-select that
|
||||
// happens to finish inside the field does not summon a
|
||||
// keyboard the reader was not asking for.
|
||||
if ended && dx.abs() <= DRAG_SLOP && dy.abs() <= DRAG_SLOP {
|
||||
state.focus_gained(render.window_region(&id, &*rsc));
|
||||
}
|
||||
}
|
||||
CursorSense::Cancel => id.edit(rsc).text.press_origin = None,
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match sense {
|
||||
CursorSense::PressStart(_) => {
|
||||
id.edit(rsc).text.press_origin = Some(pos);
|
||||
}
|
||||
CursorSense::Pressing(_) => {
|
||||
let ctx = id.edit(rsc);
|
||||
if let Some(origin) = ctx.text.press_origin
|
||||
&& ((pos.x - origin.x).abs() > DRAG_SLOP || (pos.y - origin.y).abs() > DRAG_SLOP)
|
||||
{
|
||||
// Past the slop before release: this is a drag, not a tap
|
||||
// -- give up the pending focus rather than granting it once
|
||||
// the finger lifts wherever it happens to be by then.
|
||||
ctx.text.press_origin = None;
|
||||
}
|
||||
}
|
||||
// The gesture was taken by somebody else, so it is not a tap and
|
||||
// must not grant focus when it ends out of this widget's sight.
|
||||
CursorSense::Cancel => id.edit(rsc).text.press_origin = None,
|
||||
CursorSense::PressEnd(_) => {
|
||||
let was_tap = id.edit(rsc).text.press_origin.take().is_some();
|
||||
if was_tap {
|
||||
let recent = state.recent_click();
|
||||
id.edit(rsc).select(pos, size, false, recent);
|
||||
state.set_focus(Some(id));
|
||||
state.focus_gained(render.window_region(&id, &*rsc));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! I4 (RUST.md): the desktop half of the AccessKit push, over
|
||||
//! `accesskit_winit`. `bench-lib.sh`'s tap-by-name goes through the
|
||||
//! platform's real accessibility tree, so this crate only has to keep that
|
||||
//! tree in sync with `ui::access::AccessTree`'s output -- nothing here
|
||||
//! reacts to an AccessKit action request, which is why the three handlers
|
||||
//! below are inert. See RUST.md's I4 box for why: on Android (and, by the
|
||||
//! same platform convention, everywhere else) a screen reader's element tap
|
||||
//! is a real touch delivered at the node's own bounds, not an action
|
||||
//! request synthesised in-process -- so the ordinary pointer path already
|
||||
//! handles it once the bounds are right.
|
||||
use accesskit::{ActionHandler, ActionRequest, ActivationHandler, DeactivationHandler, TreeUpdate};
|
||||
|
||||
pub struct NullActivationHandler;
|
||||
impl ActivationHandler for NullActivationHandler {
|
||||
fn request_initial_tree(&mut self) -> Option<TreeUpdate> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NullActionHandler;
|
||||
impl ActionHandler for NullActionHandler {
|
||||
fn do_action(&mut self, _request: ActionRequest) {}
|
||||
}
|
||||
|
||||
pub struct NullDeactivationHandler;
|
||||
impl DeactivationHandler for NullDeactivationHandler {
|
||||
fn deactivate_accessibility(&mut self) {}
|
||||
}
|
||||
@@ -27,6 +27,10 @@ pub struct App<State: AppState> {
|
||||
|
||||
impl<State: AppState> App<State> {
|
||||
pub fn run() {
|
||||
// The desktop's `main` in everything but name -- see
|
||||
// `super::logging`'s doc for why the logger goes here and what
|
||||
// its absence hid.
|
||||
super::logging::install(log::LevelFilter::Info);
|
||||
let event_loop = EventLoop::with_user_event().build().unwrap();
|
||||
let proxy = event_loop.create_proxy();
|
||||
event_loop
|
||||
|
||||
+17
-67
@@ -1,78 +1,28 @@
|
||||
use crate::prelude::*;
|
||||
use std::time::{Duration, Instant};
|
||||
use winit::dpi::{LogicalPosition, LogicalSize};
|
||||
use winit::dpi::{PhysicalPosition, PhysicalSize};
|
||||
|
||||
pub struct Selector;
|
||||
|
||||
impl<Rsc: HasEvents, W: Widget + 'static> WidgetAttr<Rsc, W> for Selector
|
||||
where
|
||||
Rsc::State: HasDefaultUiState,
|
||||
{
|
||||
type Input = WeakWidget<TextEdit>;
|
||||
|
||||
fn run(rsc: &mut Rsc, container: WeakWidget<W>, id: Self::Input) {
|
||||
rsc.register_event(container, CursorSense::click_or_drag(), move |ctx, rsc| {
|
||||
let region = ctx.data.render.window_region(&id).unwrap();
|
||||
let id_pos = region.top_left;
|
||||
let container_pos = ctx.data.render.window_region(&container).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<T: HasDefaultUiState> FocusHost for T {
|
||||
fn recent_click(&mut self) -> bool {
|
||||
crate::attr::recent_click(&mut self.default_state_mut().last_click)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Selectable;
|
||||
|
||||
impl<Rsc: HasEvents> WidgetAttr<Rsc, TextEdit> for Selectable
|
||||
where
|
||||
Rsc::State: HasDefaultUiState,
|
||||
{
|
||||
type Input = ();
|
||||
|
||||
fn run(rsc: &mut Rsc, id: WeakWidget<TextEdit>, _: 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<WeakWidget<TextEdit>>) {
|
||||
self.default_state_mut().focus = id;
|
||||
}
|
||||
}
|
||||
|
||||
fn select(
|
||||
rsc: &mut impl UiRsc,
|
||||
render: &UiRenderState,
|
||||
state: &mut impl HasDefaultUiState,
|
||||
id: WeakWidget<TextEdit>,
|
||||
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) {
|
||||
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||
self.default_state().focus == Some(id)
|
||||
}
|
||||
|
||||
fn focus_gained(&mut self, region: Option<PixelRegion>) {
|
||||
let state = self.default_state_mut();
|
||||
let Some(region) = region else { return };
|
||||
state.window.set_ime_allowed(true);
|
||||
// Physical, like everything else this backend hands winit --
|
||||
// `default::content_scale`.
|
||||
state.window.set_ime_cursor_area(
|
||||
LogicalPosition::<f32>::from(region.top_left.tuple()),
|
||||
LogicalSize::<f32>::from(region.size().tuple()),
|
||||
PhysicalPosition::<f32>::from(region.top_left.tuple()),
|
||||
PhysicalSize::<f32>::from(region.size().tuple()),
|
||||
);
|
||||
}
|
||||
state.focus = Some(id);
|
||||
}
|
||||
@@ -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 {}
|
||||
+17
-1
@@ -1,4 +1,10 @@
|
||||
// `CursorState::time` is the sample's own time on every backend. winit
|
||||
// carries no timestamp on a pointer event, so the moment it is handed to
|
||||
// us is the closest measurement available here -- which is also what the
|
||||
// drag code used to do for itself with `Instant::now()`, before Android's
|
||||
// batched samples made the difference matter (see `sense::CursorState`).
|
||||
use crate::prelude::*;
|
||||
use std::time::Instant;
|
||||
use winit::{
|
||||
event::{MouseButton, MouseScrollDelta, WindowEvent},
|
||||
keyboard::{Key, NamedKey},
|
||||
@@ -11,13 +17,19 @@ pub struct Input {
|
||||
}
|
||||
|
||||
impl Input {
|
||||
/// winit's pointer coordinates are physical pixels, which is the
|
||||
/// space the whole tree is laid out and hit-tested in -- see
|
||||
/// `default::content_scale`. Nothing is converted here; `dp(...)`
|
||||
/// resolves against the density at layout time instead.
|
||||
pub fn event(&mut self, event: &WindowEvent) -> bool {
|
||||
match event {
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
self.cursor.pos = Vec2::new(position.x as f32, position.y as f32);
|
||||
self.cursor.exists = true;
|
||||
self.cursor.time = Instant::now();
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
self.cursor.time = Instant::now();
|
||||
let buttons = &mut self.cursor.buttons;
|
||||
let pressed = state.is_pressed();
|
||||
match button {
|
||||
@@ -37,6 +49,7 @@ impl Input {
|
||||
delta.y = 0.0;
|
||||
}
|
||||
self.cursor.scroll_delta = delta;
|
||||
self.cursor.time = Instant::now();
|
||||
}
|
||||
WindowEvent::CursorLeft { .. } => {
|
||||
self.cursor.exists = false;
|
||||
@@ -67,9 +80,12 @@ impl Input {
|
||||
}
|
||||
|
||||
impl DefaultUiState {
|
||||
/// Physical pixels, matching `WindowEvent::Resized` (what
|
||||
/// `UiRenderState::resize` is given) and the swapchain -- see
|
||||
/// `default::content_scale`.
|
||||
pub fn window_size(&self) -> Vec2 {
|
||||
let size = self.renderer.window().inner_size();
|
||||
(size.width, size.height).into()
|
||||
Vec2::new(size.width as f32, size.height as f32)
|
||||
}
|
||||
|
||||
pub fn cursor_state(&self) -> &CursorState {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! A stderr logger for the desktop entry point.
|
||||
//!
|
||||
//! Without one, `log::` calls on this side go nowhere: `log`'s default is
|
||||
//! a no-op logger, and nothing in `desktop-app` or the examples ever
|
||||
//! installed a real one. That is how iris came to have a renderer that
|
||||
//! silently fell back to GLES (and, on this VM, on to llvmpipe when the
|
||||
//! host took its GPU away) with **no record anywhere of what
|
||||
//! drew the frame** -- a layer-2 screenshot off llvmpipe and one off the
|
||||
//! host GPU are the same PNG, and the difference is exactly what a
|
||||
//! screenshot is being taken to judge.
|
||||
//!
|
||||
//! Installed by [`DefaultApp::run`](super::app::DefaultApp::run) rather
|
||||
//! than by a library call somewhere, because that function already takes
|
||||
//! over the process -- it owns the event loop and does not return -- so
|
||||
//! it is the desktop's `main` in everything but name, and one install
|
||||
//! there covers `desktop-app` and every example at once. `try_init`
|
||||
//! rather than `init`: a binary that installed its own logger first keeps
|
||||
//! it, and a second `DefaultApp::run` in one process is not an error.
|
||||
//!
|
||||
//! Deliberately not `env_logger`. All this owes the reader is a level and
|
||||
//! a line, which is a page of code against a dependency plus its own
|
||||
//! filter dialect; the Android side is `android_logger` for the same
|
||||
//! reason -- one line per platform's own convention.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use log::{Level, LevelFilter, Log, Metadata, Record};
|
||||
|
||||
/// Reads one level name from `RUST_LOG` -- `off`, `error`, `warn`,
|
||||
/// `info`, `debug`, `trace`, case-insensitively. **Not env_logger's
|
||||
/// per-module filter syntax**: anything else is ignored and the default
|
||||
/// stands, rather than being silently read as "off", since a typo that
|
||||
/// turned logging off would be indistinguishable from a quiet program.
|
||||
fn level_from_env(default: LevelFilter) -> LevelFilter {
|
||||
match std::env::var("RUST_LOG") {
|
||||
Ok(text) => text.trim().parse().unwrap_or(default),
|
||||
Err(_) => default,
|
||||
}
|
||||
}
|
||||
|
||||
struct StderrLogger {
|
||||
level: LevelFilter,
|
||||
}
|
||||
|
||||
impl Log for StderrLogger {
|
||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||
metadata.level() <= self.level
|
||||
}
|
||||
|
||||
fn log(&self, record: &Record) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
// One write, not a `writeln!` per part: two threads logging at
|
||||
// once interleave otherwise, and the frame and input traces are
|
||||
// both written from whichever thread produced them.
|
||||
let line = format!(
|
||||
"{level:<5} {target}: {args}\n",
|
||||
level = match record.level() {
|
||||
Level::Error => "ERROR",
|
||||
Level::Warn => "WARN",
|
||||
Level::Info => "INFO",
|
||||
Level::Debug => "DEBUG",
|
||||
Level::Trace => "TRACE",
|
||||
},
|
||||
target = record.target(),
|
||||
args = record.args(),
|
||||
);
|
||||
let _ = std::io::stderr().write_all(line.as_bytes());
|
||||
}
|
||||
|
||||
fn flush(&self) {
|
||||
let _ = std::io::stderr().flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the stderr logger unless this process already has one.
|
||||
/// Defaults to `info`, which is where the renderer says which adapter it
|
||||
/// got; `RUST_LOG=debug` adds iris's own per-frame lines.
|
||||
pub fn install(default: LevelFilter) {
|
||||
let level = level_from_env(default);
|
||||
let logger = Box::leak(Box::new(StderrLogger { level }));
|
||||
if log::set_logger(logger).is_ok() {
|
||||
log::set_max_level(level);
|
||||
}
|
||||
}
|
||||
+145
-43
@@ -11,26 +11,53 @@ use winit::{
|
||||
window::{Window, WindowAttributes},
|
||||
};
|
||||
|
||||
mod access;
|
||||
mod app;
|
||||
mod attr;
|
||||
mod event;
|
||||
mod input;
|
||||
mod logging;
|
||||
mod platform;
|
||||
mod render;
|
||||
mod sense;
|
||||
mod state;
|
||||
mod task;
|
||||
|
||||
pub use access::*;
|
||||
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<Event> = EventLoopProxy<Event>;
|
||||
|
||||
/// The desktop's `content_scale`: physical pixels per dp, the same
|
||||
/// quantity Android reads from `DisplayMetrics.density` and feeds to
|
||||
/// `UiRenderState::set_density` (`android::view::AndroidUiState::
|
||||
/// content_scale`'s field comment). Everything in this backend is
|
||||
/// physical pixels -- the window size, the pointer, the widget tree --
|
||||
/// and `dp(...)` is what resolves against this at layout time, exactly
|
||||
/// as on the phone. That is a correction from an earlier version that
|
||||
/// divided winit's coordinates into a separate "logical" space instead:
|
||||
/// it left `UiRenderState::resize` (physical, from `WindowEvent::
|
||||
/// Resized`) and the window uniform (logical) disagreeing on any
|
||||
/// display whose scale factor is not 1.0, and it rasterised glyphs at
|
||||
/// one resolution to display them at another -- the blur the phone's own
|
||||
/// stopgap produced before `dp` existed.
|
||||
///
|
||||
/// **`IRIS_SCALE` overrides it**, which is how a phone-shaped desktop
|
||||
/// window runs the phone's density (`run-headless.sh --phone`,
|
||||
/// docs/RUST.md's layer 2). An unparsable value is a typo in a command
|
||||
/// somebody just typed, so it says so and uses the window's own answer
|
||||
/// rather than silently laying out at the wrong density.
|
||||
pub fn content_scale(window: &Window) -> f32 {
|
||||
match std::env::var("IRIS_SCALE") {
|
||||
Err(_) => window.scale_factor() as f32,
|
||||
Ok(text) => match text.trim().parse::<f32>() {
|
||||
Ok(scale) if scale > 0.0 => scale,
|
||||
_ => {
|
||||
log::warn!("IRIS_SCALE={text:?} is not a positive number; using the window's own");
|
||||
window.scale_factor() as f32
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DefaultUiState {
|
||||
pub root: Option<StrongWidget>,
|
||||
pub renderer: UiRenderer,
|
||||
@@ -40,6 +67,17 @@ pub struct DefaultUiState {
|
||||
pub window: Arc<Window>,
|
||||
pub ime: usize,
|
||||
pub last_click: Instant,
|
||||
/// I4 (RUST.md): pushed through in `DefaultApp::window_event`'s
|
||||
/// `RedrawRequested` arm, from `access`'s output. Built in
|
||||
/// `DefaultApp::new`, which is the only place with the
|
||||
/// `&ActiveEventLoop` `accesskit_winit::Adapter::with_direct_handlers`
|
||||
/// needs -- see that constructor's doc comment on why the window must
|
||||
/// still be invisible when it is called.
|
||||
pub access_adapter: accesskit_winit::Adapter,
|
||||
/// The AccessKit tree itself -- see `iris_core::AccessTree`'s doc
|
||||
/// comment for the flat shape and why it only rebuilds on a real
|
||||
/// change.
|
||||
pub access: AccessTree,
|
||||
}
|
||||
|
||||
impl HasRoot for DefaultUiState {
|
||||
@@ -49,7 +87,7 @@ impl HasRoot for DefaultUiState {
|
||||
}
|
||||
|
||||
impl DefaultUiState {
|
||||
pub fn new(window: impl Into<Arc<Window>>) -> Self {
|
||||
pub fn new(window: impl Into<Arc<Window>>, access_adapter: accesskit_winit::Adapter) -> Self {
|
||||
let window = window.into();
|
||||
Self {
|
||||
root: None,
|
||||
@@ -60,6 +98,8 @@ impl DefaultUiState {
|
||||
ime: 0,
|
||||
last_click: Instant::now(),
|
||||
focus: None,
|
||||
access_adapter,
|
||||
access: AccessTree::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,13 +228,35 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
||||
type Event = State::Event;
|
||||
|
||||
fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy<Self::Event>) -> Self {
|
||||
// `accesskit_winit::Adapter::with_direct_handlers` panics if the
|
||||
// window is already visible when it's built, so the window is
|
||||
// created hidden and only shown once the adapter exists -- the one
|
||||
// extra step I4 (RUST.md) needs here. The three handlers are inert
|
||||
// (see `access.rs`): a screen reader's tap is a real touch at the
|
||||
// node's bounds, not an action request this process has to answer.
|
||||
let window = event_loop
|
||||
.create_window(State::window_attributes())
|
||||
.create_window(State::window_attributes().with_visible(false))
|
||||
.unwrap();
|
||||
let default_state = DefaultUiState::new(window);
|
||||
let access_adapter = accesskit_winit::Adapter::with_direct_handlers(
|
||||
event_loop,
|
||||
&window,
|
||||
NullActivationHandler,
|
||||
NullActionHandler,
|
||||
NullDeactivationHandler,
|
||||
);
|
||||
window.set_visible(true);
|
||||
let default_state = DefaultUiState::new(window, access_adapter);
|
||||
let (mut rsc, task_recv) = DefaultRsc::init(default_state.window.clone());
|
||||
// Both copies of the density, set before the first widget is
|
||||
// built so text shapes at the right size on the opening frame --
|
||||
// the same pair `android::view::new_peer` sets from
|
||||
// `content_scale`. See `iris_core::TextData::density` for why the
|
||||
// shaper keeps its own.
|
||||
let scale = content_scale(default_state.window.as_ref());
|
||||
rsc.ui.text.density = scale;
|
||||
let state = State::new(default_state, &mut rsc, proxy);
|
||||
let render = UiRenderState::new();
|
||||
let mut render = UiRenderState::new();
|
||||
render.set_density(scale);
|
||||
Self {
|
||||
rsc,
|
||||
state,
|
||||
@@ -220,6 +282,12 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
||||
}
|
||||
|
||||
let ui_state = state.default_state_mut();
|
||||
// Required by `accesskit_winit` on every window event, not just the
|
||||
// ones this backend otherwise cares about -- some platform adapters
|
||||
// rely on it to notice activation (a screen reader turning on).
|
||||
ui_state
|
||||
.access_adapter
|
||||
.process_event(&ui_state.window, &event);
|
||||
let input_changed = ui_state.input.event(&event);
|
||||
let cursor_state = ui_state.cursor_state().clone();
|
||||
let old = ui_state.focus;
|
||||
@@ -227,6 +295,31 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
||||
ui_state.focus = None;
|
||||
}
|
||||
if input_changed {
|
||||
// The winit half of `iris::input` (`sense::log_input_event`'s
|
||||
// own doc): no batching here, so `historical` is always empty
|
||||
// -- winit hands one `WindowEvent` per pointer sample, unlike
|
||||
// Android's `MotionEvent`. The action is read back off the
|
||||
// buttons `Input::event` just updated, the same test
|
||||
// `GestureOutcome`'s callers already use to tell a press from a
|
||||
// release. Computed only when tracing is on, same reasoning as
|
||||
// `log_input_event` itself gating on it.
|
||||
if crate::diagnostics::trace_enabled() {
|
||||
let action = if cursor_state.buttons.left.is_start() {
|
||||
"down"
|
||||
} else if cursor_state.buttons.left.is_end() {
|
||||
"up"
|
||||
} else {
|
||||
"move"
|
||||
};
|
||||
let t_ms = cursor_state.time.duration_since(render.epoch()).as_millis() as u64;
|
||||
crate::sense::log_input_event(
|
||||
action,
|
||||
cursor_state.pos.x,
|
||||
cursor_state.pos.y,
|
||||
t_ms,
|
||||
&[],
|
||||
);
|
||||
}
|
||||
let window_size = ui_state.window_size();
|
||||
render.run_sensors(rsc, state, cursor_state, window_size);
|
||||
}
|
||||
@@ -239,14 +332,53 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
||||
match &event {
|
||||
WindowEvent::CloseRequested => event_loop.exit(),
|
||||
WindowEvent::RedrawRequested => {
|
||||
// Before the draw, so this frame shows this instant's
|
||||
// position (`UiData::tick_animations`' own doc), and the
|
||||
// window is asked for another frame while anything is
|
||||
// still moving -- the winit half of what
|
||||
// `IrisViewPeer::render`'s `post_frame_callback` does on
|
||||
// Android. Nothing else in iris moves without an input
|
||||
// event.
|
||||
let frame_start = std::time::Instant::now();
|
||||
let animating = rsc.ui_mut().tick_animations(frame_start);
|
||||
let ui_state = state.default_state_mut();
|
||||
render.update(&ui_state.root, rsc);
|
||||
ui_state.renderer.update(&mut rsc.ui, render);
|
||||
let draw_start = std::time::Instant::now();
|
||||
ui_state.renderer.draw();
|
||||
crate::diagnostics::log_frame(render, frame_start, draw_start.elapsed(), animating);
|
||||
if animating {
|
||||
ui_state.window.request_redraw();
|
||||
}
|
||||
// I4 (RUST.md): only produces a `TreeUpdate` when the named
|
||||
// set actually changed this frame -- see `AccessTree`'s doc
|
||||
// comment. `render` reflects the draw that just happened,
|
||||
// so `resolved_region`/`window_region` inside it report a
|
||||
// moved subtree's *new* position, not last frame's.
|
||||
if let Some(tree_update) = ui_state.access.update(rsc.widgets(), render, rsc) {
|
||||
ui_state.access_adapter.update_if_active(|| tree_update);
|
||||
}
|
||||
}
|
||||
WindowEvent::Resized(size) => {
|
||||
render.resize((size.width, size.height));
|
||||
ui_state.renderer.resize(size)
|
||||
}
|
||||
// Dragging the window to a display with a different scale.
|
||||
// Both copies again, the pair `new` sets at startup -- read
|
||||
// through `content_scale` rather than from the event, so
|
||||
// `IRIS_SCALE` still pins the density it was given (the
|
||||
// `--phone` window must not follow the monitor). winit sends
|
||||
// the matching `Resized` separately. Before 2026-09-07 this
|
||||
// event was unhandled, so every `dp` and every rasterised
|
||||
// glyph stayed at the density the window opened on
|
||||
// (docs/REVIEW-2026-09-07.md's R5) -- invisible on this
|
||||
// machine, where every display is 1.0.
|
||||
WindowEvent::ScaleFactorChanged { .. } => {
|
||||
let scale = content_scale(ui_state.window.as_ref());
|
||||
rsc.ui.text.density = scale;
|
||||
render.set_density(scale);
|
||||
ui_state.window.request_redraw();
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
if let Some(sel) = ui_state.focus
|
||||
&& event.state.is_pressed()
|
||||
@@ -309,12 +441,6 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
||||
}
|
||||
}
|
||||
|
||||
pub trait RscIdx<Rsc> {
|
||||
type Output;
|
||||
fn get(self, rsc: &Rsc) -> &Self::Output;
|
||||
fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output;
|
||||
}
|
||||
|
||||
impl<State: 'static, I: RscIdx<DefaultRsc<State>>> std::ops::Index<I> for DefaultRsc<State> {
|
||||
type Output = I::Output;
|
||||
|
||||
@@ -328,27 +454,3 @@ impl<State: 'static, I: RscIdx<DefaultRsc<State>>> std::ops::IndexMut<I> for Def
|
||||
index.get_mut(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Widget, Rsc: UiRsc> RscIdx<Rsc> for WeakWidget<W> {
|
||||
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<T: 'static, Rsc: HasWidgetState> RscIdx<Rsc> for WeakState<T> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use crate::platform::OpenUrl;
|
||||
use crate::prelude::HasDefaultUiState;
|
||||
|
||||
/// The desktop's URL opener: the platform's own "open this with whatever
|
||||
/// is registered for it" command, detached so a browser starting slowly
|
||||
/// cannot stall the event loop.
|
||||
///
|
||||
/// A command rather than a crate: `xdg-open`/`open`/`start` is what every
|
||||
/// such crate shells out to anyway, and this is one call site.
|
||||
impl<T: HasDefaultUiState> OpenUrl for T {
|
||||
fn open_url(&mut self, url: &str) {
|
||||
let (program, first): (&str, &[&str]) = if cfg!(target_os = "macos") {
|
||||
("open", &[])
|
||||
} else if cfg!(target_os = "windows") {
|
||||
// `start` is a shell builtin, and its first argument is the
|
||||
// window title -- an empty one, or a URL containing `&` ends
|
||||
// up split.
|
||||
("cmd", &["/C", "start", ""])
|
||||
} else {
|
||||
("xdg-open", &[])
|
||||
};
|
||||
match std::process::Command::new(program)
|
||||
.args(first)
|
||||
.arg(url)
|
||||
.spawn()
|
||||
{
|
||||
Ok(_) => {}
|
||||
// Named with the command that failed and the link it was for,
|
||||
// since neither is recoverable from the OS error alone.
|
||||
Err(e) => log::warn!("could not open {url} with {program}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
+125
-22
@@ -1,4 +1,5 @@
|
||||
use iris_core::{UiData, UiLimits, UiRenderNode, UiRenderState};
|
||||
use crate::task::RequestRedraw;
|
||||
use iris_core::{UiData, UiRenderNode, UiRenderState, util::Vec2};
|
||||
use pollster::FutureExt;
|
||||
use std::sync::Arc;
|
||||
use wgpu::*;
|
||||
@@ -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<Window>,
|
||||
surface: Surface<'static>,
|
||||
@@ -22,7 +29,16 @@ impl UiRenderer {
|
||||
}
|
||||
|
||||
pub fn draw(&mut self) {
|
||||
let output = self.surface.get_current_texture().unwrap();
|
||||
let output = match self.surface.get_current_texture() {
|
||||
CurrentSurfaceTexture::Success(texture)
|
||||
| CurrentSurfaceTexture::Suboptimal(texture) => texture,
|
||||
// wgpu 30 turned this Result into an enum; every arm here was an
|
||||
// `Err` the previous `.unwrap()` panicked on, except `Occluded`,
|
||||
// which is new. Named rather than swallowed: a window that stops
|
||||
// presenting silently is the state this file's `pre_present_notify`
|
||||
// comment was written about.
|
||||
other => panic!("no surface texture to draw into: {other:?}"),
|
||||
};
|
||||
let view = output
|
||||
.texture
|
||||
.create_view(&TextureViewDescriptor::default());
|
||||
@@ -45,14 +61,25 @@ impl UiRenderer {
|
||||
}
|
||||
|
||||
self.queue.submit(std::iter::once(encoder.finish()));
|
||||
output.present();
|
||||
// Immediately before presenting, so the windowing system can schedule
|
||||
// the frame. On Wayland this is what ties the commit to the surface's
|
||||
// frame callback; without it a frame drawn when nothing else follows
|
||||
// could sit unpresented, and the window kept the layout it had before
|
||||
// the compositor's first resize -- intermittently, on about a fifth of
|
||||
// starts, with nothing left to flush it.
|
||||
self.window.pre_present_notify();
|
||||
self.queue.present(output);
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, size: &PhysicalSize<u32>) {
|
||||
self.config.width = size.width;
|
||||
self.config.height = size.height;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
self.ui.resize(size, &self.queue);
|
||||
// Physical, matching `new`'s own seed -- see the comment there.
|
||||
self.ui.resize(
|
||||
Vec2::new(size.width as f32, size.height as f32),
|
||||
&self.queue,
|
||||
);
|
||||
}
|
||||
|
||||
fn create_encoder(device: &Device) -> CommandEncoder {
|
||||
@@ -64,10 +91,44 @@ impl UiRenderer {
|
||||
pub fn new(window: Arc<Window>) -> Self {
|
||||
let size = window.inner_size();
|
||||
|
||||
let instance = Instance::new(&InstanceDescriptor {
|
||||
backends: Backends::PRIMARY,
|
||||
..Default::default()
|
||||
// `force-gles` on the desktop too, not just on Android: the
|
||||
// GLES backend has behaviour of its own (a one-layer array
|
||||
// texture is a `GL_TEXTURE_2D` -- see
|
||||
// `GpuTextures::create_array_texture`), and a machine with a
|
||||
// real GPU is where that is cheap to reproduce and screenshot.
|
||||
let mut backends = if cfg!(feature = "force-gles") {
|
||||
Backends::GL
|
||||
} else {
|
||||
Backends::PRIMARY
|
||||
};
|
||||
// The display handle comes from the window rather than being left
|
||||
// out: wgpu 30 asks for it whenever a GLES surface is going to be
|
||||
// presented on Wayland, which is exactly what the fallback below
|
||||
// produces on this machine.
|
||||
let mut instance = Instance::new(InstanceDescriptor {
|
||||
backends,
|
||||
..InstanceDescriptor::new_with_display_handle(Box::new(window.clone()))
|
||||
});
|
||||
// The same fallback the Android backend grew in 85869d0, and for
|
||||
// the same reason: a machine can advertise a Vulkan ICD with no
|
||||
// device behind it, and refusing to draw at all because the only
|
||||
// usable adapter is a GLES one is iris's bug rather than the
|
||||
// machine's. On this VM the Vulkan device disappears whenever
|
||||
// the host refuses a virtio-gpu context, so `run-headless.sh` --
|
||||
// layer 2 of the test rig -- aborted with `Could not get
|
||||
// adapter!` while GL was sitting there working. Probed before the
|
||||
// surface exists, matching Android, where an instance carrying
|
||||
// both backends fails worse than one carrying the wrong one.
|
||||
if backends != Backends::GL && instance.enumerate_adapters(backends).block_on().is_empty() {
|
||||
log::warn!(
|
||||
"iris renderer: no {backends:?} adapter on this machine, falling back to GLES"
|
||||
);
|
||||
backends = Backends::GL;
|
||||
instance = Instance::new(InstanceDescriptor {
|
||||
backends,
|
||||
..InstanceDescriptor::new_with_display_handle(Box::new(window.clone()))
|
||||
});
|
||||
}
|
||||
|
||||
let surface = instance
|
||||
.create_surface(window.clone())
|
||||
@@ -78,25 +139,48 @@ impl UiRenderer {
|
||||
power_preference: PowerPreference::default(),
|
||||
compatible_surface: Some(&surface),
|
||||
force_fallback_adapter: false,
|
||||
..Default::default()
|
||||
})
|
||||
.block_on()
|
||||
.expect("Could not get adapter!");
|
||||
.unwrap_or_else(|error| {
|
||||
panic!("No usable GPU adapter for backends {backends:?}: {error}")
|
||||
});
|
||||
|
||||
let ui_limits = UiLimits::default();
|
||||
// Say which adapter won, in the same words the Android backend
|
||||
// uses. Without it a layer-2 screenshot or frame time from this
|
||||
// window carries no record of what drew it, and the two cases that
|
||||
// matter look identical in the PNG: the host's real GPU, and
|
||||
// llvmpipe after this VM lost its virtio-gpu contexts. That
|
||||
// happened on 2026-09-08, and the only reason anyone noticed is
|
||||
// that the fallback above did not exist yet and the app aborted
|
||||
// instead. A silent fallback needs this line to stay honest.
|
||||
{
|
||||
let info = adapter.get_info();
|
||||
log::info!(
|
||||
"iris renderer: {name} ({backend:?}, {driver}{driver_info}) on {backends:?}",
|
||||
name = info.name,
|
||||
backend = info.backend,
|
||||
driver = info.driver,
|
||||
driver_info = if info.driver_info.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", info.driver_info)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// No features beyond what wgpu asks for by default, and no
|
||||
// binding-array limits: the atlas is one texture_2d_array and a
|
||||
// standalone image is its own ordinary bind group, neither of which
|
||||
// needs descriptor indexing. See TEXTURES.md's "Recommended shape"
|
||||
// for why the old binding array asked for
|
||||
// VK_EXT_descriptor_indexing unconditionally and did not survive a
|
||||
// real share of Android GPUs. `iris_core::device_limits()` is
|
||||
// shared with the Android backend; see its own doc for why it is
|
||||
// not simply `Limits::default()`.
|
||||
let (device, queue) = adapter
|
||||
.request_device(&DeviceDescriptor {
|
||||
required_features: Features::TEXTURE_BINDING_ARRAY
|
||||
| Features::PARTIALLY_BOUND_BINDING_ARRAY
|
||||
| Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING,
|
||||
required_limits: Limits {
|
||||
max_binding_array_elements_per_shader_stage: ui_limits
|
||||
.max_binding_array_elements_per_shader_stage(),
|
||||
max_binding_array_sampler_elements_per_shader_stage: ui_limits
|
||||
.max_binding_array_sampler_elements_per_shader_stage(),
|
||||
max_buffer_size: 1 << 30,
|
||||
..Default::default()
|
||||
},
|
||||
required_limits: iris_core::device_limits(),
|
||||
..Default::default()
|
||||
})
|
||||
.block_on()
|
||||
@@ -113,9 +197,16 @@ impl UiRenderer {
|
||||
let config = SurfaceConfiguration {
|
||||
usage: TextureUsages::RENDER_ATTACHMENT,
|
||||
format: surface_format,
|
||||
// wgpu 30's new field; `Auto` is what every earlier version did.
|
||||
color_space: SurfaceColorSpace::Auto,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
present_mode: PresentMode::AutoNoVsync,
|
||||
// Vsync, because a toolkit aiming at battery life must not present
|
||||
// frames a display will never show: AutoNoVsync accepts them as
|
||||
// fast as the GPU will take them, so a redraw burst costs whatever
|
||||
// the hardware can be made to do rather than one frame.
|
||||
// AutoVsync picks Fifo, which every backend supports.
|
||||
present_mode: PresentMode::AutoVsync,
|
||||
alpha_mode: surface_caps.alpha_modes[0],
|
||||
desired_maximum_frame_latency: 2,
|
||||
view_formats: vec![],
|
||||
@@ -125,7 +216,19 @@ impl UiRenderer {
|
||||
|
||||
let encoder = Self::create_encoder(&device);
|
||||
|
||||
let ui = UiRenderNode::new(&device, &queue, &config, ui_limits);
|
||||
// Unlike the Android backend, the desktop backend has no on-screen
|
||||
// fallback to show a diagnostic through, so a renderer-creation
|
||||
// failure still panics here -- but now with wgpu's full "Caused
|
||||
// by:" chain as the message, since `UiRenderNode::new` returns it
|
||||
// rather than letting wgpu's own default handler panic first (see
|
||||
// that function's doc comment).
|
||||
// Physical size, the same units the swapchain, `WindowEvent::
|
||||
// Resized`, the pointer and the widget tree all use -- see
|
||||
// `default::content_scale` for why this backend stopped dividing
|
||||
// into a separate logical space, and what disagreed while it did.
|
||||
let physical_size = Vec2::new(size.width as f32, size.height as f32);
|
||||
let ui = UiRenderNode::new(&device, &queue, &config, physical_size)
|
||||
.expect("Could not create iris render node!");
|
||||
|
||||
Self {
|
||||
surface,
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
use crate::prelude::*;
|
||||
use std::{
|
||||
ops::{BitOr, Deref, DerefMut},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum CursorButton {
|
||||
Left,
|
||||
Right,
|
||||
Middle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub enum CursorSense {
|
||||
PressStart(CursorButton),
|
||||
Pressing(CursorButton),
|
||||
PressEnd(CursorButton),
|
||||
HoverStart,
|
||||
Hovering,
|
||||
HoverEnd,
|
||||
Scroll,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CursorSenses(Vec<CursorSense>);
|
||||
|
||||
impl Event for CursorSenses {
|
||||
type Data<'a> = CursorData<'a>;
|
||||
type State = SensorState;
|
||||
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
|
||||
if let Some(sense) = should_run(self, &data.cursor, data.hover) {
|
||||
let mut data = data.clone();
|
||||
data.sense = sense;
|
||||
Some(data)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CursorSense {
|
||||
pub fn click() -> Self {
|
||||
Self::PressStart(CursorButton::Left)
|
||||
}
|
||||
pub fn click_or_drag() -> CursorSenses {
|
||||
Self::click() | Self::Pressing(CursorButton::Left)
|
||||
}
|
||||
pub fn unclick() -> Self {
|
||||
Self::PressEnd(CursorButton::Left)
|
||||
}
|
||||
pub fn is_dragging(&self) -> bool {
|
||||
matches!(self, CursorSense::Pressing(CursorButton::Left))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct CursorState {
|
||||
pub pos: Vec2,
|
||||
pub exists: bool,
|
||||
pub buttons: CursorButtons,
|
||||
pub scroll_delta: Vec2,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct CursorButtons {
|
||||
pub left: ActivationState,
|
||||
pub middle: ActivationState,
|
||||
pub right: ActivationState,
|
||||
}
|
||||
|
||||
impl CursorButtons {
|
||||
pub fn select(&self, button: &CursorButton) -> &ActivationState {
|
||||
match button {
|
||||
CursorButton::Left => &self.left,
|
||||
CursorButton::Right => &self.right,
|
||||
CursorButton::Middle => &self.middle,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn end_frame(&mut self) {
|
||||
self.left.end_frame();
|
||||
self.middle.end_frame();
|
||||
self.right.end_frame();
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = (CursorButton, &ActivationState)> {
|
||||
[
|
||||
CursorButton::Left,
|
||||
CursorButton::Middle,
|
||||
CursorButton::Right,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|b| (b, self.select(&b)))
|
||||
}
|
||||
}
|
||||
|
||||
impl CursorState {
|
||||
pub fn end_frame(&mut self) {
|
||||
self.buttons.end_frame();
|
||||
self.scroll_delta = Vec2::ZERO;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub enum ActivationState {
|
||||
Start,
|
||||
On,
|
||||
End,
|
||||
#[default]
|
||||
Off,
|
||||
}
|
||||
|
||||
/// this and other similar stuff has a generic
|
||||
/// because I kind of want to make CursorModule generic
|
||||
/// or basically have some way to have custom senses
|
||||
/// that depend on active widget positions
|
||||
/// but I'm not sure how or if worth it
|
||||
pub struct Sensor<Ctx: HasEvents, Data> {
|
||||
pub senses: CursorSenses,
|
||||
pub f: Rc<dyn EventFn<Ctx, Data>>,
|
||||
}
|
||||
|
||||
pub type SenseShape = UiRegion;
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct SensorState {
|
||||
pub hover: ActivationState,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CursorData<'a> {
|
||||
/// where this widget was hit
|
||||
pub pos: Vec2,
|
||||
pub size: Vec2,
|
||||
pub scroll_delta: Vec2,
|
||||
pub hover: ActivationState,
|
||||
pub cursor: CursorState,
|
||||
/// the first sense that triggered this
|
||||
pub sense: CursorSense,
|
||||
pub render: &'a UiRenderState,
|
||||
}
|
||||
|
||||
pub trait SensorUi {
|
||||
fn run_sensors<Rsc: HasEvents>(
|
||||
&self,
|
||||
rsc: &mut Rsc,
|
||||
state: &mut Rsc::State,
|
||||
cursor: CursorState,
|
||||
window_size: Vec2,
|
||||
);
|
||||
}
|
||||
|
||||
impl SensorUi for UiRenderState {
|
||||
fn run_sensors<Rsc: HasEvents>(
|
||||
&self,
|
||||
rsc: &mut Rsc,
|
||||
state: &mut Rsc::State,
|
||||
cursor: CursorState,
|
||||
window_size: Vec2,
|
||||
) {
|
||||
// in order to remove this take, need to store active list in UiRenderState somehow
|
||||
// this would probably be done through a generic parameter that adds yet another rsc /
|
||||
// state like thing, but local to render state, and is passed to UiRsc events so you can
|
||||
// update it there?
|
||||
let mut active = std::mem::take(&mut rsc.events_mut().get_type::<CursorSense>().active);
|
||||
for layer in self.layers.indices().rev() {
|
||||
let mut sensed = false;
|
||||
for (id, sensor) in active.get_mut(&layer).into_flat_iter() {
|
||||
let shape = self.active.get(id).unwrap().region;
|
||||
let region = shape.to_px(window_size);
|
||||
let in_shape = cursor.exists && region.contains(cursor.pos);
|
||||
sensor.hover.update(in_shape);
|
||||
if sensor.hover == ActivationState::Off {
|
||||
continue;
|
||||
}
|
||||
sensed = true;
|
||||
|
||||
let cursor = cursor.clone();
|
||||
|
||||
let data = CursorData {
|
||||
pos: cursor.pos - region.top_left,
|
||||
size: region.bot_right - region.top_left,
|
||||
scroll_delta: cursor.scroll_delta,
|
||||
hover: sensor.hover,
|
||||
cursor,
|
||||
// this does not have any meaning;
|
||||
// might wanna set up Event to have a prepare stage
|
||||
sense: CursorSense::Hovering,
|
||||
render: self,
|
||||
};
|
||||
rsc.run_event::<CursorSense>(*id, data, state);
|
||||
}
|
||||
if sensed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
rsc.events_mut().get_type::<CursorSense>().active = active;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_run(
|
||||
senses: &CursorSenses,
|
||||
cursor: &CursorState,
|
||||
hover: ActivationState,
|
||||
) -> Option<CursorSense> {
|
||||
for sense in senses.iter() {
|
||||
if match sense {
|
||||
CursorSense::PressStart(button) => cursor.buttons.select(button).is_start(),
|
||||
CursorSense::Pressing(button) => cursor.buttons.select(button).is_on(),
|
||||
CursorSense::PressEnd(button) => cursor.buttons.select(button).is_end(),
|
||||
CursorSense::HoverStart => hover.is_start(),
|
||||
CursorSense::Hovering => hover.is_on(),
|
||||
CursorSense::HoverEnd => hover.is_end(),
|
||||
CursorSense::Scroll => cursor.scroll_delta != Vec2::ZERO,
|
||||
} {
|
||||
return Some(*sense);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl ActivationState {
|
||||
pub fn is_start(&self) -> bool {
|
||||
*self == Self::Start
|
||||
}
|
||||
pub fn is_on(&self) -> bool {
|
||||
*self == Self::Start || *self == Self::On
|
||||
}
|
||||
pub fn is_end(&self) -> bool {
|
||||
*self == Self::End
|
||||
}
|
||||
pub fn is_off(&self) -> bool {
|
||||
*self == Self::End || *self == Self::Off
|
||||
}
|
||||
pub fn update(&mut self, on: bool) {
|
||||
*self = match *self {
|
||||
Self::Start => match on {
|
||||
true => Self::On,
|
||||
false => Self::End,
|
||||
},
|
||||
Self::On => match on {
|
||||
true => Self::On,
|
||||
false => Self::End,
|
||||
},
|
||||
Self::End => match on {
|
||||
true => Self::Start,
|
||||
false => Self::Off,
|
||||
},
|
||||
Self::Off => match on {
|
||||
true => Self::Start,
|
||||
false => Self::Off,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn end_frame(&mut self) {
|
||||
match self {
|
||||
Self::Start => *self = Self::On,
|
||||
Self::End => *self = Self::Off,
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventLike for CursorSense {
|
||||
type Event = CursorSenses;
|
||||
fn into_event(self) -> Self::Event {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for CursorSenses {
|
||||
type Target = Vec<CursorSense>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for CursorSenses {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CursorSense> for CursorSenses {
|
||||
fn from(val: CursorSense) -> Self {
|
||||
CursorSenses(vec![val])
|
||||
}
|
||||
}
|
||||
|
||||
impl BitOr for CursorSense {
|
||||
type Output = CursorSenses;
|
||||
|
||||
fn bitor(self, rhs: Self) -> Self::Output {
|
||||
CursorSenses(vec![self, rhs])
|
||||
}
|
||||
}
|
||||
|
||||
impl BitOr<CursorSense> for CursorSenses {
|
||||
type Output = Self;
|
||||
|
||||
fn bitor(mut self, rhs: CursorSense) -> Self::Output {
|
||||
self.0.push(rhs);
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! The trace toggle for the `iris::input`/`iris::frame` diagnostics (Iris's
|
||||
//! 2026-09-07 request: "add another button to copy input event info ...
|
||||
//! instrument a lot of the code with timings"), and the one place both
|
||||
//! call sites' `iris::frame` line is written from.
|
||||
//!
|
||||
//! **Why a crate-level flag instead of `log::log_enabled!`/
|
||||
//! `log::set_max_level`**: the app already installs its logger at
|
||||
//! `LevelFilter::Debug` (`iris/android-app/src/lib.rs`'s `JNI_OnLoad`), so
|
||||
//! a `log::Level::Debug` line reaches `client_core::log_ring`'s ring
|
||||
//! regardless of what this instrument would prefer -- `RingLogger::enabled`
|
||||
//! is unconditionally `true` by design (its own doc: "the ring wants
|
||||
//! everything"). So the level alone cannot give these two targets a
|
||||
//! default-off switch; the gate has to live on this side, checked before
|
||||
//! `log::debug!` is even reached.
|
||||
//!
|
||||
//! **Why default off matters**: the ring is 2000 lines / 256 KiB
|
||||
//! (`client_core::log_ring::DEFAULT_MAX_LINES`/`DEFAULT_MAX_BYTES`), and a
|
||||
//! 120Hz session logging both a line per touch sample and a line per frame
|
||||
//! fills that in seconds -- so a caller turns this on only for the length
|
||||
//! of whatever is being investigated, and the report says so at its top
|
||||
//! (a caller's job; see `iris::diagnostics::trace_enabled` used at the top
|
||||
//! of whatever builds the report).
|
||||
//!
|
||||
//! **Not yet wired to a control**: the Diagnostics pane that would hold the
|
||||
//! switch is in `iris/android-app/src/bench_client.rs`, which another agent
|
||||
//! has open at the same time this was written. `set_trace` is the whole
|
||||
//! surface a button needs; wiring one is a follow-up.
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use iris_core::UiRenderState;
|
||||
|
||||
static TRACE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Turns the `iris::input`/`iris::frame` `debug!` lines on or off. Off by
|
||||
/// default -- see the module doc for why turning the level on alone would
|
||||
/// not do it.
|
||||
pub fn set_trace(on: bool) {
|
||||
TRACE.store(on, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether the `iris::input`/`iris::frame` lines are enabled right now --
|
||||
/// what a report's header reads before deciding what to say about the
|
||||
/// lines it does or doesn't hold (UI_RULES.md: "design the unknown state
|
||||
/// first").
|
||||
pub fn trace_enabled() -> bool {
|
||||
TRACE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// One `iris::frame` line, called once per frame from each backend's own
|
||||
/// frame function -- `android::view::IrisViewPeer::render`,
|
||||
/// `default::DefaultApp::window_event`'s `RedrawRequested` arm, and
|
||||
/// `harness::Harness::frame` -- after the draw (or, on the harness, where a
|
||||
/// draw would be; `draw` is `Duration::ZERO` there since nothing is
|
||||
/// actually submitted to a GPU).
|
||||
///
|
||||
/// `render.update(...)` must already have run this frame: this reads back
|
||||
/// what it recorded (`UiRenderState::last_layout_duration`/
|
||||
/// `last_redraw_kind`/`frame_number`) rather than timing anything itself,
|
||||
/// so a caller's own measurement of the phase around `update()` and around
|
||||
/// its own draw call are the only two `Instant` pairs in the whole path --
|
||||
/// see each call site's own comment for why it is not restructured to fit
|
||||
/// this instead.
|
||||
pub fn log_frame(render: &UiRenderState, now: Instant, draw: Duration, animating: bool) {
|
||||
if !trace_enabled() {
|
||||
return;
|
||||
}
|
||||
let since_input = render
|
||||
.time_since_input(now)
|
||||
.map(|d| format!("{}ms", d.as_millis()))
|
||||
.unwrap_or_else(|| "none".to_string());
|
||||
log::debug!(
|
||||
target: "iris::frame",
|
||||
"iris frame: n={} now={}ms since_input={since_input} layout={:?} draw={:?} \
|
||||
redraw={:?} primitives={} animating={animating}",
|
||||
render.frame_number(),
|
||||
now.duration_since(render.epoch()).as_millis(),
|
||||
render.last_layout_duration(),
|
||||
draw,
|
||||
render.last_redraw_kind(),
|
||||
render.active_primitive_count(),
|
||||
);
|
||||
}
|
||||
+24
-4
@@ -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<Rsc: HasEvents, Tag>: WidgetLike<Rsc, Tag> {
|
||||
fn on<E: EventLike>(
|
||||
@@ -30,13 +44,19 @@ impl<WL: WidgetLike<Rsc, Tag>, Rsc: HasEvents, Tag> Eventable<Rsc, Tag> for WL {
|
||||
|
||||
widget_trait! {
|
||||
pub trait TaskEventable<Rsc: HasEvents + HasTasks>;
|
||||
fn task_on<'a, E: EventLike, F: AsyncWidgetEventFn<Rsc, WL::Widget>>(
|
||||
/// No `Data: Send` bound, deliberately: the registered handler below
|
||||
/// takes `|_, rsc|` and the event's data never crosses into the
|
||||
/// spawned future -- `AsyncEventIdCtx` carries the widget id and the
|
||||
/// task handle and nothing else. The bound used to be here anyway, and
|
||||
/// it was the whole reason `CursorData`'s pointer state was behind a
|
||||
/// `Mutex` rather than owned by the input handler (Iris, 2026-09-08:
|
||||
/// never reach for a lock first).
|
||||
fn task_on<E: EventLike, F: AsyncWidgetEventFn<Rsc, WL::Widget>>(
|
||||
self,
|
||||
event: E,
|
||||
f: F,
|
||||
) -> impl WidgetIdFn<Rsc, WL::Widget>
|
||||
where <E::Event as Event>::Data<'a>: Send,
|
||||
for<'b> F::CallRefFuture<'b>: Send,
|
||||
where for<'b> F::CallRefFuture<'b>: Send,
|
||||
{
|
||||
let f = Arc::new(f);
|
||||
move |rsc| {
|
||||
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
//! Layer 1 of docs/RUST.md's "Three test layers": a whole screen driven
|
||||
//! in-process with **no window, no compositor and no GPU**, on an
|
||||
//! explicit clock and a replayed touch stream.
|
||||
//!
|
||||
//! `layout_tests.rs` and `sense_tests.rs` already build trees over
|
||||
//! `UiRenderState` with a hand-rolled `Rsc` each; this is the same idea
|
||||
//! carried far enough to open a real app screen (`transcript-ui`'s, over
|
||||
//! the bench fixture -- see the `transcript-fixture` crate) at the
|
||||
//! phone's size and density, feed it a recorded flick, and assert on
|
||||
//! where the list ended up. What it answers that the emulator cannot:
|
||||
//! Android batches a 120Hz flick into one or two `MotionEvent`s
|
||||
//! (`CursorState::time`), and a `ui-trace` swipe is many evenly-spaced
|
||||
//! ones -- so the gesture shape a finger actually makes is only
|
||||
//! reproducible from a *file* of timestamped samples.
|
||||
//!
|
||||
//! It is a third backend in the sense `default/` and `android/` are, and
|
||||
//! deliberately the smallest one: the platform half of each of those
|
||||
//! (a surface, an IME, a URL opener) becomes a recorded fact here --
|
||||
//! [`HarnessState::keyboard_shown`], [`HarnessState::opened_urls`] --
|
||||
//! so a test can assert the platform *was asked*, which is the only
|
||||
//! thing either backend does with those calls anyway.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||
//! let screen = transcript_ui::build(&mut h.rsc, &mut h.state, rows);
|
||||
//! h.frame(0);
|
||||
//! h.replay(&TouchScript::parse(include_str!("flick.touch"))?);
|
||||
//! h.frames_until(20, 2_000, 8);
|
||||
//! ```
|
||||
|
||||
use crate::prelude::*;
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// One replayed pointer sample: what Android's `MotionEvent` carries, cut
|
||||
/// down to the part iris reads (`IrisViewPeer::on_touch_event`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TouchAction {
|
||||
Down,
|
||||
Move,
|
||||
Up,
|
||||
/// The gesture taken away by the system (a parent view claiming it, a
|
||||
/// call arriving, the swipe up from the bottom edge to leave the
|
||||
/// app). It ends the press, because a release that never arrives
|
||||
/// leaves pointer capture held forever -- but it is not a release,
|
||||
/// and nothing follows from it: no tap, no selection, no fling. See
|
||||
/// `CursorState::cancelled`, which is what it sets.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
impl TouchAction {
|
||||
fn parse(word: &str) -> Option<Self> {
|
||||
match word {
|
||||
"down" => Some(Self::Down),
|
||||
"move" => Some(Self::Move),
|
||||
"up" => Some(Self::Up),
|
||||
"cancel" => Some(Self::Cancel),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The inverse of [`Self::parse`] -- what [`Harness::touch`] hands
|
||||
/// [`crate::sense::log_input_event`], so an `iris::input` line and a
|
||||
/// `.touch` file agree on one spelling of each action.
|
||||
pub fn word(self) -> &'static str {
|
||||
match self {
|
||||
Self::Down => "down",
|
||||
Self::Move => "move",
|
||||
Self::Up => "up",
|
||||
Self::Cancel => "cancel",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct TouchSample {
|
||||
/// Milliseconds since the start of the recording -- the sample's own
|
||||
/// time, which becomes `CursorState::time`. See that field's doc for
|
||||
/// why a replay may not date its samples by when the loop got to
|
||||
/// them.
|
||||
pub t_ms: u64,
|
||||
pub action: TouchAction,
|
||||
pub pos: Vec2,
|
||||
}
|
||||
|
||||
/// A recorded gesture: one `t_ms action x y` line per sample, `#` and
|
||||
/// blank lines ignored. Deliberately a plain text file rather than a
|
||||
/// serialisation format -- it is written by hand as often as it is
|
||||
/// recorded, and a diff of one has to be readable.
|
||||
pub struct TouchScript {
|
||||
pub samples: Vec<TouchSample>,
|
||||
}
|
||||
|
||||
impl TouchScript {
|
||||
/// Parses a script, naming the line and what was wrong with it: these
|
||||
/// are hand-written files, so a typo is the ordinary case and
|
||||
/// "expected 4 fields" without a line number is not enough to fix it.
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let mut samples: Vec<TouchSample> = Vec::new();
|
||||
for (i, line) in text.lines().enumerate() {
|
||||
let line = line.split('#').next().unwrap_or("").trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let at = |what: &str| format!("touch script line {}: {what}: {line:?}", i + 1);
|
||||
let mut words = line.split_whitespace();
|
||||
let (Some(t), Some(action), Some(x), Some(y), None) = (
|
||||
words.next(),
|
||||
words.next(),
|
||||
words.next(),
|
||||
words.next(),
|
||||
words.next(),
|
||||
) else {
|
||||
return Err(at("expected `t_ms action x y`"));
|
||||
};
|
||||
let t_ms: u64 = t.parse().map_err(|_| at("t_ms is not a whole number"))?;
|
||||
let action = TouchAction::parse(action)
|
||||
.ok_or_else(|| at("action is not down/move/up/cancel"))?;
|
||||
let x: f32 = x.parse().map_err(|_| at("x is not a number"))?;
|
||||
let y: f32 = y.parse().map_err(|_| at("y is not a number"))?;
|
||||
if let Some(last) = samples.last()
|
||||
&& t_ms < last.t_ms
|
||||
{
|
||||
return Err(at("samples must be in time order"));
|
||||
}
|
||||
samples.push(TouchSample {
|
||||
t_ms,
|
||||
action,
|
||||
pos: Vec2::new(x, y),
|
||||
});
|
||||
}
|
||||
Ok(Self { samples })
|
||||
}
|
||||
|
||||
/// The last sample's time, i.e. how long the recording runs.
|
||||
pub fn end_ms(&self) -> u64 {
|
||||
self.samples.last().map(|s| s.t_ms).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts the frames something asked for without drawing any -- the
|
||||
/// harness's `RequestRedraw`. A `LazySpan` coasting through a fling asks for
|
||||
/// the next frame through this (`UiData::animate` and `Widget::tick`), so a test can
|
||||
/// tell "nothing moved" from "nothing was even asked to move".
|
||||
#[derive(Default)]
|
||||
pub struct RedrawCounter(AtomicUsize);
|
||||
|
||||
impl RedrawCounter {
|
||||
pub fn count(&self) -> usize {
|
||||
self.0.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestRedraw for RedrawCounter {
|
||||
fn request_redraw(&self) {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The harness's app state: what each real backend keeps for the platform
|
||||
/// half, recorded instead of performed.
|
||||
pub struct HarnessState {
|
||||
pub root: Option<StrongWidget>,
|
||||
pub focus: Option<WeakWidget<TextEdit>>,
|
||||
last_click: Instant,
|
||||
/// How many times a tap asked for the keyboard (`FocusHost::
|
||||
/// focus_gained` with a region -- `showSoftInput` on Android,
|
||||
/// `set_ime_cursor_area` on winit). The platform's own answer is not
|
||||
/// available here, so this says what was *asked*, and a test must not
|
||||
/// read it as "the IME is up".
|
||||
pub keyboard_shown: usize,
|
||||
/// Every URL a tapped link asked the platform to open, in order.
|
||||
pub opened_urls: Vec<String>,
|
||||
}
|
||||
|
||||
impl HarnessState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
root: None,
|
||||
focus: None,
|
||||
last_click: Instant::now(),
|
||||
keyboard_shown: 0,
|
||||
opened_urls: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HasRoot for HarnessState {
|
||||
fn set_root(&mut self, root: StrongWidget) {
|
||||
self.root = Some(root);
|
||||
}
|
||||
}
|
||||
|
||||
impl FocusHost for HarnessState {
|
||||
fn recent_click(&mut self) -> bool {
|
||||
crate::attr::recent_click(&mut self.last_click)
|
||||
}
|
||||
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
|
||||
self.focus = id;
|
||||
}
|
||||
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||
self.focus == Some(id)
|
||||
}
|
||||
fn focus_gained(&mut self, region: Option<PixelRegion>) {
|
||||
if region.is_some() {
|
||||
self.keyboard_shown += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenUrl for HarnessState {
|
||||
fn open_url(&mut self, url: &str) {
|
||||
self.opened_urls.push(url.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// The harness's `Rsc` -- identical in substance to `DefaultRsc`/
|
||||
/// `AndroidRsc` minus the windowing, for the same reason those two are
|
||||
/// separate types (`AndroidRsc`'s own doc).
|
||||
pub struct HarnessRsc {
|
||||
pub ui: UiData,
|
||||
pub events: EventManager<Self>,
|
||||
pub tasks: Tasks<Self>,
|
||||
pub state: WidgetState,
|
||||
_state: PhantomData<HarnessState>,
|
||||
}
|
||||
|
||||
impl UiRsc for HarnessRsc {
|
||||
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 HarnessRsc {
|
||||
type State = HarnessState;
|
||||
}
|
||||
|
||||
impl HasEvents for HarnessRsc {
|
||||
fn events(&self) -> &EventManager<Self> {
|
||||
&self.events
|
||||
}
|
||||
fn events_mut(&mut self) -> &mut EventManager<Self> {
|
||||
&mut self.events
|
||||
}
|
||||
}
|
||||
|
||||
impl HasTasks for HarnessRsc {
|
||||
fn tasks_mut(&mut self) -> &mut Tasks<Self> {
|
||||
&mut self.tasks
|
||||
}
|
||||
}
|
||||
|
||||
impl HasWidgetState for HarnessRsc {
|
||||
fn widget_state(&self) -> &WidgetState {
|
||||
&self.state
|
||||
}
|
||||
fn widget_state_mut(&mut self) -> &mut WidgetState {
|
||||
&mut self.state
|
||||
}
|
||||
}
|
||||
|
||||
impl<I: RscIdx<HarnessRsc>> std::ops::Index<I> for HarnessRsc {
|
||||
type Output = I::Output;
|
||||
fn index(&self, index: I) -> &Self::Output {
|
||||
index.get(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<I: RscIdx<HarnessRsc>> std::ops::IndexMut<I> for HarnessRsc {
|
||||
fn index_mut(&mut self, index: I) -> &mut Self::Output {
|
||||
index.get_mut(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// A screen running with no window: the widget tree, the frame loop and
|
||||
/// the pointer, all advanced by the caller. See the module doc.
|
||||
pub struct Harness {
|
||||
pub rsc: HarnessRsc,
|
||||
pub render: UiRenderState,
|
||||
pub state: HarnessState,
|
||||
task_recv: TaskMsgReceiver<HarnessRsc>,
|
||||
redraws: Arc<RedrawCounter>,
|
||||
cursor: CursorState,
|
||||
/// Time zero. Every `t_ms` in this harness is an offset from here, so
|
||||
/// nothing reads the wall clock -- see [`Self::at`].
|
||||
base: Instant,
|
||||
size: Vec2,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
/// `size` is in physical pixels and `density` is physical pixels per
|
||||
/// dp, the pair Android reads from the surface and
|
||||
/// `DisplayMetrics.density` (`AndroidUiState::content_scale`). The
|
||||
/// phone's own numbers are `transcript_fixture::PHONE_SIZE`/
|
||||
/// `PHONE_SCALE`.
|
||||
pub fn new(size: Vec2, density: f32) -> Self {
|
||||
let redraws = Arc::new(RedrawCounter::default());
|
||||
let (tasks, task_recv) = Tasks::init(redraws.clone());
|
||||
let mut rsc = HarnessRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
tasks,
|
||||
state: WidgetState::default(),
|
||||
_state: PhantomData,
|
||||
};
|
||||
rsc.ui.text.density = density;
|
||||
let mut render = UiRenderState::new();
|
||||
render.set_density(density);
|
||||
render.resize(size);
|
||||
Self {
|
||||
rsc,
|
||||
render,
|
||||
state: HarnessState::new(),
|
||||
task_recv,
|
||||
redraws,
|
||||
cursor: CursorState::default(),
|
||||
base: Instant::now(),
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Instant` this harness means by `t_ms`. Public because a
|
||||
/// caller driving `ScrollController::tick` or `DragGesture` by hand needs
|
||||
/// to date those calls on the same clock the touch samples use.
|
||||
pub fn at(&self, t_ms: u64) -> Instant {
|
||||
self.base + Duration::from_millis(t_ms)
|
||||
}
|
||||
|
||||
pub fn size(&self) -> Vec2 {
|
||||
self.size
|
||||
}
|
||||
|
||||
/// How many frames were asked for so far -- see [`RedrawCounter`].
|
||||
pub fn redraws(&self) -> usize {
|
||||
self.redraws.count()
|
||||
}
|
||||
|
||||
/// One frame at `t_ms`: drain finished tasks, advance anything
|
||||
/// animating, lay out and "draw". The same three steps
|
||||
/// `DefaultApp::window_event`'s `RedrawRequested` arm and
|
||||
/// `IrisViewPeer::render` take, minus handing primitives to a GPU.
|
||||
pub fn frame(&mut self, t_ms: u64) {
|
||||
while let Ok(update) = self.task_recv.try_recv() {
|
||||
update(&mut self.state, &mut self.rsc);
|
||||
}
|
||||
let now = self.at(t_ms);
|
||||
let animating = self.rsc.ui.tick_animations(now);
|
||||
self.render.update(&self.state.root, &mut self.rsc);
|
||||
// No GPU here, so there is no draw phase to time -- `draw` is
|
||||
// always zero. `layout`/`redraw`/`primitives` are still real,
|
||||
// because `render.update` just ran; see
|
||||
// `iris::diagnostics::log_frame`'s own doc for why this reads
|
||||
// those back rather than timing anything itself.
|
||||
crate::diagnostics::log_frame(&self.render, now, Duration::ZERO, animating);
|
||||
}
|
||||
|
||||
/// Frames every `step_ms` up to and including `end_ms` -- what a
|
||||
/// fling needs, since it moves only while something ticks it
|
||||
/// (`ScrollController::fling`'s doc). Returns the time of the last frame run.
|
||||
pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 {
|
||||
debug_assert!(step_ms > 0, "a frame loop with no step never ends");
|
||||
let mut t = from_ms;
|
||||
while t <= end_ms {
|
||||
self.frame(t);
|
||||
t += step_ms;
|
||||
}
|
||||
t - step_ms
|
||||
}
|
||||
|
||||
/// One pointer sample through the sensors, then the frame it belongs
|
||||
/// to -- `IrisViewPeer::on_touch_event` and `after_input`, in one
|
||||
/// call. Each sample is its own input frame, dated by the sample
|
||||
/// rather than by when this ran.
|
||||
pub fn touch(&mut self, action: TouchAction, pos: Vec2, t_ms: u64) {
|
||||
self.cursor.time = self.at(t_ms);
|
||||
self.cursor.pos = pos;
|
||||
match action {
|
||||
TouchAction::Down => {
|
||||
self.cursor.exists = true;
|
||||
self.cursor.buttons.left.update(true);
|
||||
}
|
||||
TouchAction::Move => {}
|
||||
TouchAction::Up => self.cursor.buttons.left.update(false),
|
||||
// The platform taking the gesture away, not the finger
|
||||
// lifting -- see `CursorState::cancelled`.
|
||||
TouchAction::Cancel => {
|
||||
self.cursor.buttons.left.update(false);
|
||||
self.cursor.cancelled = true;
|
||||
}
|
||||
}
|
||||
// Layer 1's half of `iris::input` (`sense::log_input_event`'s own
|
||||
// doc): no batching happens here, so `historical` is always empty
|
||||
// and `t_ms` is the script's own column, which is what makes this
|
||||
// round-trip through `report_to_touch.py` back into an identical
|
||||
// `TouchScript`.
|
||||
crate::sense::log_input_event(action.word(), pos.x, pos.y, t_ms, &[]);
|
||||
let cursor = self.cursor.clone();
|
||||
self.render
|
||||
.run_sensors(&mut self.rsc, &mut self.state, cursor, self.size);
|
||||
self.frame(t_ms);
|
||||
self.cursor.end_frame();
|
||||
}
|
||||
|
||||
/// Replays a whole recorded gesture. Nothing is inserted between the
|
||||
/// samples: a file with three lines produces three input frames, so
|
||||
/// the batched shape a real flick arrives in is preserved exactly as
|
||||
/// recorded rather than smoothed into evenly-spaced motion.
|
||||
pub fn replay(&mut self, script: &TouchScript) {
|
||||
for sample in &script.samples {
|
||||
self.touch(sample.action, sample.pos, sample.t_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1079
File diff suppressed because it is too large.
Load diff
+37
-2
@@ -1,24 +1,59 @@
|
||||
#![feature(unboxed_closures)]
|
||||
#![feature(fn_traits)]
|
||||
#![feature(gen_blocks)]
|
||||
#![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 diagnostics;
|
||||
pub mod event;
|
||||
pub mod harness;
|
||||
pub mod platform;
|
||||
pub mod sense;
|
||||
pub mod state;
|
||||
pub mod task;
|
||||
pub mod widget;
|
||||
|
||||
#[cfg(test)]
|
||||
mod access_tests;
|
||||
#[cfg(test)]
|
||||
mod layout_tests;
|
||||
#[cfg(test)]
|
||||
mod sense_tests;
|
||||
|
||||
pub use iris_core as core;
|
||||
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 platform::*;
|
||||
pub use sense::*;
|
||||
pub use state::*;
|
||||
pub use task::*;
|
||||
pub use widget::*;
|
||||
|
||||
pub use iris_core::util::Vec2;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Capabilities a widget tree needs from whatever is hosting it, that
|
||||
//! neither iris nor the app can perform itself.
|
||||
//!
|
||||
//! Same shape as [`crate::attr::FocusHost`], and for the same reason: the
|
||||
//! interface is declared here, below, and implemented by each backend
|
||||
//! above (`default/platform.rs`, `android/platform.rs`), so a widget can
|
||||
//! ask for the capability by trait bound instead of a caller threading a
|
||||
//! callback down through every builder.
|
||||
|
||||
/// Hand a URL to whatever the platform opens URLs with.
|
||||
///
|
||||
/// One method rather than a general "run an intent"/"exec" surface: the
|
||||
/// only thing a transcript needs is to follow a link a reader tapped, and
|
||||
/// a narrower capability is a narrower thing to get wrong.
|
||||
///
|
||||
/// **Nothing is reported back.** There is no answer worth branching on --
|
||||
/// the platform either shows a browser or does not, and both are outside
|
||||
/// this process -- so failures are logged where they happen (each impl)
|
||||
/// rather than turned into a `Result` every call site would discard.
|
||||
pub trait OpenUrl {
|
||||
fn open_url(&mut self, url: &str);
|
||||
}
|
||||
+3171
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,781 @@
|
||||
//! IRIS_TODO.md's "Input does not fall through by input type": a widget
|
||||
//! that only registered `click()` used to also block a `ScrollArea` meant for
|
||||
//! whatever is behind it, because `run_sensors` decided "consumed, stop
|
||||
//! looking at lower layers" from mere hover, not from anything actually
|
||||
//! matching. Exercised as a plain unit test for the same reason
|
||||
//! `layout_tests.rs` is one: `UiRenderState` and a minimal `HasEvents`
|
||||
//! impl need no GPU or window.
|
||||
|
||||
use crate::prelude::*;
|
||||
use std::{cell::Cell, rc::Rc, time::Instant};
|
||||
|
||||
struct SenseRsc {
|
||||
ui: UiData,
|
||||
events: EventManager<SenseRsc>,
|
||||
}
|
||||
|
||||
impl UiRsc for SenseRsc {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
impl HasState for SenseRsc {
|
||||
type State = ();
|
||||
}
|
||||
|
||||
impl HasEvents for SenseRsc {
|
||||
fn events(&self) -> &EventManager<Self> {
|
||||
&self.events
|
||||
}
|
||||
fn events_mut(&mut self) -> &mut EventManager<Self> {
|
||||
&mut self.events
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_at(pos: Vec2) -> CursorState {
|
||||
CursorState {
|
||||
pos,
|
||||
exists: true,
|
||||
buttons: Default::default(),
|
||||
scroll_delta: Vec2::ZERO,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
|
||||
let mut rsc = SenseRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
|
||||
// Both cover the whole window -- the button "sitting over" the list,
|
||||
// the case in IRIS_TODO.md's report.
|
||||
let list = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||
let list_weak = list.weak();
|
||||
let button = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED));
|
||||
let button_weak = button.weak();
|
||||
|
||||
let scrolled = Rc::new(Cell::new(false));
|
||||
let clicked = Rc::new(Cell::new(false));
|
||||
{
|
||||
let scrolled = scrolled.clone();
|
||||
rsc.register_event(list_weak, CursorSense::Scroll, move |_ctx, _rsc| {
|
||||
scrolled.set(true);
|
||||
});
|
||||
}
|
||||
{
|
||||
let clicked = clicked.clone();
|
||||
rsc.register_event(button_weak, CursorSense::click(), move |_ctx, _rsc| {
|
||||
clicked.set(true);
|
||||
});
|
||||
}
|
||||
|
||||
// A Stack draws its children on separate layers in order, which is
|
||||
// exactly the "one thing drawn over another" shape `run_sensors`
|
||||
// walks top layer first.
|
||||
let root = rsc
|
||||
.ui
|
||||
.widgets
|
||||
.add_strong(Stack {
|
||||
children: vec![list.any(), button.any()],
|
||||
size: StackSize::default(),
|
||||
})
|
||||
.any();
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 100.0));
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let mut state = ();
|
||||
let mut scroll_cursor = cursor_at((50.0, 50.0).into());
|
||||
scroll_cursor.scroll_delta = (0.0, 10.0).into();
|
||||
render.run_sensors(&mut rsc, &mut state, scroll_cursor, (100.0, 100.0).into());
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
assert!(
|
||||
scrolled.get(),
|
||||
"a scroll over the button must still reach the list underneath it"
|
||||
);
|
||||
assert!(
|
||||
!clicked.get(),
|
||||
"a scroll is not a click; the button must not have fired"
|
||||
);
|
||||
|
||||
let mut click_cursor = cursor_at((50.0, 50.0).into());
|
||||
click_cursor.buttons.left = ActivationState::Start;
|
||||
render.run_sensors(&mut rsc, &mut state, click_cursor, (100.0, 100.0).into());
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
assert!(
|
||||
clicked.get(),
|
||||
"the button on top must still receive an actual click"
|
||||
);
|
||||
}
|
||||
|
||||
/// The bug behind "finger flings do nothing" (RUST.md's P0 phone report,
|
||||
/// defect 2): a fast gesture's `PressEnd` can land at a screen position
|
||||
/// nothing is registered at -- past the edge of whatever widget noticed
|
||||
/// the press, in a gap, or off the loaded content entirely. Before pointer
|
||||
/// capture, `run_sensors`' hit test simply delivered nothing that frame,
|
||||
/// so a widget mid-drag never saw its release and never got a chance to
|
||||
/// start a fling. `PointerRequests::capture`/`DragGesture` fix this
|
||||
/// by giving the drag's widget every frame regardless of where the
|
||||
/// pointer is, including the terminal `Drop` in place of `PressEnd`.
|
||||
#[test]
|
||||
fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
|
||||
let mut rsc = SenseRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
|
||||
// A small draggable widget in the corner -- the release below lands
|
||||
// far outside it, exactly the "moved off the hit region" case.
|
||||
let draggable = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
|
||||
let draggable_weak = draggable.weak();
|
||||
|
||||
let dropped = Rc::new(Cell::new(false));
|
||||
{
|
||||
let dropped = dropped.clone();
|
||||
rsc.register_event(
|
||||
draggable_weak,
|
||||
CursorSense::click_or_drag() | CursorSense::unclick() | CursorSense::Drop,
|
||||
move |ctx, rsc| match ctx.data.sense {
|
||||
CursorSense::PressStart(_) | CursorSense::Pressing(_) => {
|
||||
// Any committed drag takes capture -- a real caller
|
||||
// would gate this on a `DragArbiter`/`DragGesture`
|
||||
// decision, but this test only needs to exercise the
|
||||
// capture-and-release mechanics themselves.
|
||||
ctx.data.pointer.capture(draggable_weak.id());
|
||||
let _ = rsc;
|
||||
}
|
||||
CursorSense::Drop => dropped.set(true),
|
||||
_ => {}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 100.0));
|
||||
render.update(&draggable, &mut rsc);
|
||||
|
||||
let mut state = ();
|
||||
let mut press = cursor_at((5.0, 5.0).into());
|
||||
press.buttons.left = ActivationState::Start;
|
||||
render.run_sensors(&mut rsc, &mut state, press, (100.0, 100.0).into());
|
||||
render.update(&draggable, &mut rsc);
|
||||
assert_eq!(
|
||||
pointer_input(&mut rsc).holder(),
|
||||
Some(draggable.id()),
|
||||
"the press should have taken capture"
|
||||
);
|
||||
|
||||
// The release lands nowhere near the widget's own region -- the exact
|
||||
// shape of a fast fling's `ACTION_UP`.
|
||||
let mut release = cursor_at((95.0, 95.0).into());
|
||||
release.buttons.left = ActivationState::End;
|
||||
render.run_sensors(&mut rsc, &mut state, release, (100.0, 100.0).into());
|
||||
render.update(&draggable, &mut rsc);
|
||||
|
||||
assert!(
|
||||
dropped.get(),
|
||||
"a release outside every widget's hit region must still reach \
|
||||
the widget holding pointer capture"
|
||||
);
|
||||
assert_eq!(
|
||||
pointer_input(&mut rsc).holder(),
|
||||
None,
|
||||
"Drop must release the capture"
|
||||
);
|
||||
}
|
||||
|
||||
/// A widget that never registers `CursorSense::Drop` at all must not be
|
||||
/// affected by someone else's capture -- capture is per-gesture, not
|
||||
/// global suppression of the whole input system for widgets that were
|
||||
/// never party to it. (Practically this matters because a captured
|
||||
/// widget's registration list still has to include `Drop` for `should_run`
|
||||
/// to ever match it; this pins that half of the contract.)
|
||||
#[test]
|
||||
fn capturing_one_widget_starves_every_other_widget_of_events() {
|
||||
let mut rsc = SenseRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
|
||||
let a = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||
let a_weak = a.weak();
|
||||
let b = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED));
|
||||
let b_weak = b.weak();
|
||||
|
||||
let b_hovered = Rc::new(Cell::new(false));
|
||||
{
|
||||
let b_hovered = b_hovered.clone();
|
||||
rsc.register_event(b_weak, CursorSense::Hovering, move |_ctx, _rsc| {
|
||||
b_hovered.set(true);
|
||||
});
|
||||
}
|
||||
|
||||
let root = rsc
|
||||
.ui
|
||||
.widgets
|
||||
.add_strong(Stack {
|
||||
children: vec![a.any(), b.any()],
|
||||
size: StackSize::default(),
|
||||
})
|
||||
.any();
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 100.0));
|
||||
render.update(&root, &mut rsc);
|
||||
pointer_input(&mut rsc).set_holder(Some(a_weak.id()));
|
||||
|
||||
let mut state = ();
|
||||
let cursor = cursor_at((50.0, 50.0).into());
|
||||
render.run_sensors(&mut rsc, &mut state, cursor, (100.0, 100.0).into());
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
assert!(
|
||||
!b_hovered.get(),
|
||||
"while a's drag holds capture, b must see no hover at all"
|
||||
);
|
||||
}
|
||||
|
||||
/// IRIS_TODO.md's "the composer has no touch-drag scroll": `ScrollArea` only
|
||||
/// answered a wheel, so a finger drag over overflowed text did nothing.
|
||||
/// End-to-end over the real wiring -- `scrollable()`'s own registration,
|
||||
/// `run_sensors`' dispatch, `ScrollController::drag`, `DragGesture`'s arbitration and
|
||||
/// pointer capture -- rather than only `ScrollController::drag`'s own unit tests in
|
||||
/// `scroll.rs`, because the registration is exactly the half those cannot
|
||||
/// see.
|
||||
#[test]
|
||||
fn a_finger_drag_over_a_scroll_area_pans_it() {
|
||||
let mut rsc = SenseRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
|
||||
// 1000px of content in a 100px window: room to pan.
|
||||
let scroll_strong = rect(UiColor::WHITE)
|
||||
.height(Len::abs(1000.0))
|
||||
.scrollable(Axis::Y, Pin::Start)
|
||||
.add_strong(&mut rsc);
|
||||
let scroll = scroll_strong.weak();
|
||||
let root = scroll_strong.any();
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 100.0));
|
||||
render.update(&root, &mut rsc);
|
||||
// `ScrollArea` reads its content length back from the draw it just did, so
|
||||
// the frame after is the first one that knows there is anything to pan
|
||||
// -- the one-frame lag LAYOUT.md section 4 documents. `scroll(0.0)` is
|
||||
// how `layout_tests.rs` asks for that second frame, and it also drops
|
||||
// `snap_end`, leaving this parked at the start of the content.
|
||||
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
|
||||
render.update(&root, &mut rsc);
|
||||
assert_eq!(rsc.ui.widgets.get(&scroll).unwrap().amt(), 0.0);
|
||||
|
||||
let mut state = ();
|
||||
let mut down = cursor_at((50.0, 80.0).into());
|
||||
down.buttons.left = ActivationState::Start;
|
||||
render.run_sensors(&mut rsc, &mut state, down, (100.0, 100.0).into());
|
||||
render.update(&root, &mut rsc);
|
||||
assert_eq!(
|
||||
rsc.ui.widgets.get(&scroll).unwrap().amt(),
|
||||
0.0,
|
||||
"the touch-down alone must not move anything"
|
||||
);
|
||||
|
||||
// Inside the slop: still a tap as far as anything can tell.
|
||||
let mut nudge = cursor_at((50.0, 80.0 - (DRAG_SLOP - 1.0)).into());
|
||||
nudge.buttons.left = ActivationState::On;
|
||||
render.run_sensors(&mut rsc, &mut state, nudge, (100.0, 100.0).into());
|
||||
render.update(&root, &mut rsc);
|
||||
assert_eq!(
|
||||
rsc.ui.widgets.get(&scroll).unwrap().amt(),
|
||||
0.0,
|
||||
"a press inside DRAG_SLOP must not scroll"
|
||||
);
|
||||
|
||||
// Past it, upward: the content follows the finger up, which for this
|
||||
// widget means more `amt`.
|
||||
let mut drag = cursor_at((50.0, 80.0 - (DRAG_SLOP + 40.0)).into());
|
||||
drag.buttons.left = ActivationState::On;
|
||||
render.run_sensors(&mut rsc, &mut state, drag, (100.0, 100.0).into());
|
||||
render.update(&root, &mut rsc);
|
||||
let after = rsc.ui.widgets.get(&scroll).unwrap().amt();
|
||||
assert!(
|
||||
(after - 40.0).abs() < 0.01,
|
||||
"expected the 40px past the slop to pan it, got {after}"
|
||||
);
|
||||
|
||||
// And the gesture holds the pointer, so the rest of it reaches this
|
||||
// widget even once the finger leaves its box.
|
||||
assert_eq!(pointer_input(&mut rsc).holder(), Some(scroll.id()));
|
||||
}
|
||||
|
||||
/// docs/REVIEW-2026-09-07.md's D4. The first `MotionEvent` a view sees can
|
||||
/// be a `Move` -- the `Down` went to another view, or the view was attached
|
||||
/// mid-gesture -- and its batched samples are older than its own
|
||||
/// timestamp. Anchoring on that timestamp clamped every one of them onto
|
||||
/// the anchor, so the tracker saw three samples at one instant, the Lsq2
|
||||
/// fit went degenerate, and the flick read 0 px/s.
|
||||
#[test]
|
||||
fn the_first_events_batched_samples_are_dated_apart() {
|
||||
const MS: i64 = 1_000_000;
|
||||
let now = Instant::now();
|
||||
// A 120Hz batch: three historical samples at 0/4/8ms and the event's
|
||||
// own at 12ms.
|
||||
let clock = PointerClock::anchored(now, 12 * MS, 0);
|
||||
|
||||
assert_eq!(
|
||||
clock.at(12 * MS),
|
||||
now,
|
||||
"the event's own sample is the one that arrived now"
|
||||
);
|
||||
let batch = [clock.at(0), clock.at(4 * MS), clock.at(8 * MS)];
|
||||
assert!(
|
||||
batch[0] < batch[1] && batch[1] < batch[2] && batch[2] < now,
|
||||
"the batch must keep the 4ms between its samples, got {:?}",
|
||||
batch
|
||||
.iter()
|
||||
.map(|t| now.duration_since(*t))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(clock.ms_since_anchor(8 * MS), 8);
|
||||
}
|
||||
|
||||
/// The same clock has to keep ordering *across* events: the sample it
|
||||
/// compares a new event's first sample against is the previous event's
|
||||
/// last one, never the anchor.
|
||||
#[test]
|
||||
fn the_clock_orders_samples_across_events() {
|
||||
const MS: i64 = 1_000_000;
|
||||
let mut clock = PointerClock::anchored(Instant::now(), 12 * MS, 0);
|
||||
let first = clock.sample(12 * MS);
|
||||
let second = clock.sample(28 * MS);
|
||||
assert!(second > first);
|
||||
assert_eq!(
|
||||
second.duration_since(first),
|
||||
std::time::Duration::from_millis(16)
|
||||
);
|
||||
}
|
||||
|
||||
/// Iris's 2026-09-08 phone report, first half: "it keeps snapping back to
|
||||
/// some position when horizontally scrolling."
|
||||
///
|
||||
/// A `ScrollArea` that has committed to a pan holds the pointer, so the
|
||||
/// gesture's end arrives as `CursorSense::Drop` -- and `scrollable`
|
||||
/// used to register `click_or_drag | unclick` only, which `should_run`
|
||||
/// never matches a `Drop` against. So the widget never learned its own
|
||||
/// gesture had ended: its `DragArbiter` stayed `Panning` at the position
|
||||
/// the finger left, and the *next* drag's first frame was measured from
|
||||
/// there and applied in one step. The registration is
|
||||
/// `CursorSense::drag_senses()` now, which is the rule for every widget
|
||||
/// driving a `DragGesture` rather than a fact about this one.
|
||||
#[test]
|
||||
fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() {
|
||||
let mut rsc = SenseRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
let scroll_strong = rect(UiColor::WHITE)
|
||||
.height(Len::abs(1000.0))
|
||||
.scrollable(Axis::Y, Pin::Start)
|
||||
.add_strong(&mut rsc);
|
||||
let scroll = scroll_strong.weak();
|
||||
let root = scroll_strong.any();
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 100.0));
|
||||
render.update(&root, &mut rsc);
|
||||
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let mut state = ();
|
||||
let win = Vec2::new(100.0, 100.0);
|
||||
let mut send = |render: &mut UiRenderState, rsc: &mut SenseRsc, y: f32, button| {
|
||||
let mut c = cursor_at((50.0, y).into());
|
||||
c.buttons.left = button;
|
||||
render.run_sensors(rsc, &mut state, c, win);
|
||||
render.update(&root, rsc);
|
||||
};
|
||||
|
||||
// One pan of 40px past the slop, then a release well outside the
|
||||
// widget -- the ordinary shape of a flick.
|
||||
send(&mut render, &mut rsc, 80.0, ActivationState::Start);
|
||||
send(
|
||||
&mut render,
|
||||
&mut rsc,
|
||||
80.0 - (DRAG_SLOP + 40.0),
|
||||
ActivationState::On,
|
||||
);
|
||||
let after_first = rsc.ui.widgets.get(&scroll).unwrap().amt();
|
||||
assert!((after_first - 40.0).abs() < 0.01, "amt={after_first}");
|
||||
send(&mut render, &mut rsc, 400.0, ActivationState::End);
|
||||
assert_eq!(
|
||||
pointer_input(&mut rsc).holder(),
|
||||
None,
|
||||
"the release must give the pointer back"
|
||||
);
|
||||
|
||||
// A second gesture, starting where the first one did. If the arbiter
|
||||
// were still panning from the release position, this first frame
|
||||
// would apply the whole distance between the two at once.
|
||||
send(&mut render, &mut rsc, 80.0, ActivationState::Start);
|
||||
let after_second = rsc.ui.widgets.get(&scroll).unwrap().amt();
|
||||
assert!(
|
||||
(after_second - after_first).abs() < 0.01,
|
||||
"a fresh touch-down moved the content by {} -- the previous \
|
||||
gesture was never closed",
|
||||
after_second - after_first,
|
||||
);
|
||||
}
|
||||
|
||||
/// The second half of the same report: "tapping sometimes seems to make
|
||||
/// the scrolling jump, particularly when tapping on things that have
|
||||
/// events like horizontal scrolling."
|
||||
///
|
||||
/// Two widgets see the same press -- a scroll area and, under it,
|
||||
/// something tracking the gesture for a list. When the scroll area
|
||||
/// captures, the other one is cut off completely: no `PressEnd`, no
|
||||
/// `Drop`. It has to be told, or its gesture stays open at an origin
|
||||
/// belonging to a finger that has long gone, and the next unrelated touch
|
||||
/// is measured from it.
|
||||
#[test]
|
||||
fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
|
||||
let mut rsc = SenseRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
|
||||
// The bystander *contains* the capturer, which is the real shape: a
|
||||
// transcript's `LazySpan` and one row's own text both track the same
|
||||
// press, and a `Stack`'s siblings would be on separate layers where
|
||||
// only the topmost is dispatched to at all.
|
||||
let capturer = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||
let capturer_weak = capturer.weak();
|
||||
let bystander = rsc.ui.widgets.add_strong(Stack {
|
||||
children: vec![capturer.any()],
|
||||
size: StackSize::default(),
|
||||
});
|
||||
let bystander_weak = bystander.weak();
|
||||
|
||||
let capturer_saw = Rc::new(Cell::new(0u32));
|
||||
{
|
||||
let capturer_saw = capturer_saw.clone();
|
||||
rsc.register_event(
|
||||
capturer_weak,
|
||||
CursorSense::drag_senses(),
|
||||
move |ctx, _rsc| {
|
||||
capturer_saw.set(capturer_saw.get() + 1);
|
||||
if matches!(ctx.data.sense, CursorSense::Pressing(_)) {
|
||||
ctx.data.pointer.capture(capturer_weak.id());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
let cancelled = Rc::new(Cell::new(0u32));
|
||||
let ended = Rc::new(Cell::new(0u32));
|
||||
{
|
||||
let (cancelled, ended) = (cancelled.clone(), ended.clone());
|
||||
rsc.register_event(
|
||||
bystander_weak,
|
||||
CursorSense::drag_senses(),
|
||||
move |ctx, _rsc| match ctx.data.sense {
|
||||
CursorSense::Cancel => cancelled.set(cancelled.get() + 1),
|
||||
CursorSense::PressEnd(_) | CursorSense::Drop => ended.set(ended.get() + 1),
|
||||
_ => {}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let root = bystander.any();
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 100.0));
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let mut state = ();
|
||||
let win = Vec2::new(100.0, 100.0);
|
||||
let mut down = cursor_at((50.0, 50.0).into());
|
||||
down.buttons.left = ActivationState::Start;
|
||||
render.run_sensors(&mut rsc, &mut state, down, win);
|
||||
render.update(&root, &mut rsc);
|
||||
assert_eq!(cancelled.get(), 0, "nothing has captured yet");
|
||||
|
||||
let mut moved = cursor_at((50.0, 20.0).into());
|
||||
moved.buttons.left = ActivationState::On;
|
||||
render.run_sensors(&mut rsc, &mut state, moved, win);
|
||||
render.update(&root, &mut rsc);
|
||||
assert!(capturer_saw.get() > 0, "the capturer never saw the press");
|
||||
assert_eq!(
|
||||
pointer_input(&mut rsc).holder(),
|
||||
Some(capturer_weak.id()),
|
||||
"the capture should have been taken on this frame"
|
||||
);
|
||||
assert_eq!(
|
||||
cancelled.get(),
|
||||
1,
|
||||
"the widget that lost the gesture must be told exactly once"
|
||||
);
|
||||
|
||||
// And exactly once: the frames after the capture reach the capturer
|
||||
// alone, so there is nothing left to cancel.
|
||||
let mut more = cursor_at((50.0, 10.0).into());
|
||||
more.buttons.left = ActivationState::On;
|
||||
render.run_sensors(&mut rsc, &mut state, more, win);
|
||||
render.update(&root, &mut rsc);
|
||||
let mut up = cursor_at((50.0, 10.0).into());
|
||||
up.buttons.left = ActivationState::End;
|
||||
render.run_sensors(&mut rsc, &mut state, up, win);
|
||||
render.update(&root, &mut rsc);
|
||||
assert_eq!(cancelled.get(), 1, "cancelled more than once");
|
||||
assert_eq!(
|
||||
ended.get(),
|
||||
0,
|
||||
"a cancelled widget must not also be told the gesture ended \
|
||||
normally -- acting on that is the tap it never made"
|
||||
);
|
||||
}
|
||||
|
||||
/// Iris's rule for nested scrolling, 2026-09-08: "it should only trigger
|
||||
/// horizontal if you drag left or right, and vertical should fall through
|
||||
/// if you drag up or down."
|
||||
///
|
||||
/// One mechanism does both, and it is `DragArbiter`'s existing axis test:
|
||||
/// each scroll area's gesture commits only on its own axis, so a drag
|
||||
/// along the other one is never claimed and the enclosing area's gesture
|
||||
/// -- which sees the same press, being an ancestor rather than a sibling
|
||||
/// layer -- is the one that commits and captures. This pins the pair,
|
||||
/// including the direction the change had no reason to touch.
|
||||
#[test]
|
||||
fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() {
|
||||
for (name, to, pans, still) in [
|
||||
("vertical", Vec2::new(50.0, 80.0 - (DRAG_SLOP + 40.0)), 0, 1),
|
||||
(
|
||||
"horizontal",
|
||||
Vec2::new(50.0 - (DRAG_SLOP + 40.0), 80.0),
|
||||
1,
|
||||
0,
|
||||
),
|
||||
] {
|
||||
let mut rsc = SenseRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
// 1000px square of content in a 100px window: room to pan either
|
||||
// way, in an X area inside a Y one.
|
||||
let seen = Rc::new(Cell::new(None));
|
||||
let record = seen.clone();
|
||||
let outer_strong = rect(UiColor::WHITE)
|
||||
.width(Len::abs(1000.0))
|
||||
.height(Len::abs(1000.0))
|
||||
.scrollable(Axis::X, Pin::Start)
|
||||
// The inner area's own handle, taken as the chain is built --
|
||||
// the whole point is to exercise `scrollable`'s real
|
||||
// registration on both, so neither is assembled by hand.
|
||||
.with_id(move |_rsc, id| {
|
||||
record.set(Some(id));
|
||||
id
|
||||
})
|
||||
.scrollable(Axis::Y, Pin::Start)
|
||||
.add_strong(&mut rsc);
|
||||
let inner = seen.get().unwrap();
|
||||
let outer = outer_strong.weak();
|
||||
let root = outer_strong.any();
|
||||
let areas = [outer, inner];
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 100.0));
|
||||
render.update(&root, &mut rsc);
|
||||
// The second frame, where each area knows its content length --
|
||||
// LAYOUT.md section 4's one-frame lag, and what drops `snap_end`.
|
||||
for a in areas {
|
||||
rsc.ui.widgets.get_mut(&a).unwrap().scroll(0.0);
|
||||
}
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let mut state = ();
|
||||
let win = Vec2::new(100.0, 100.0);
|
||||
let mut down = cursor_at((50.0, 80.0).into());
|
||||
down.buttons.left = ActivationState::Start;
|
||||
render.run_sensors(&mut rsc, &mut state, down, win);
|
||||
render.update(&root, &mut rsc);
|
||||
let mut drag = cursor_at(to);
|
||||
drag.buttons.left = ActivationState::On;
|
||||
render.run_sensors(&mut rsc, &mut state, drag, win);
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let moved = rsc.ui.widgets.get(&areas[pans]).unwrap().amt();
|
||||
let unmoved = rsc.ui.widgets.get(&areas[still]).unwrap().amt();
|
||||
assert!(
|
||||
(moved - 40.0).abs() < 0.01,
|
||||
"a {name} drag should have panned the {name} area by the 40px \
|
||||
past the slop, got {moved}"
|
||||
);
|
||||
assert_eq!(
|
||||
unmoved, 0.0,
|
||||
"a {name} drag must not move the area that owns the other axis"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Iris's 2026-09-08 report: "if I try to scroll vertically while a
|
||||
/// horizontal scroll animation is still active, it stays locked to the
|
||||
/// horizontal scroll", with her own diagnosis -- "tapping outside of
|
||||
/// something that a fling is currently active for should have no code in
|
||||
/// common with the fling that could influence it."
|
||||
///
|
||||
/// She was right that it was global state, and this is where it lived.
|
||||
/// `run_sensors` runs a widget one more frame *after* the pointer has
|
||||
/// left it, so a `HoverEnd` can fire ([`ActivationState::End`], which is
|
||||
/// not `Off`) -- and `should_run` derived a press from the button alone,
|
||||
/// so that farewell frame also carried a `PressStart`. A widget nowhere
|
||||
/// near the finger therefore opened a gesture, and a `ScrollArea` catching
|
||||
/// its own fling commits with no slop, so it captured the pointer and the
|
||||
/// whole gesture went to it.
|
||||
///
|
||||
/// Two areas side by side here rather than one, because "the press went
|
||||
/// to the wrong widget" and "the press went nowhere" are different
|
||||
/// failures and only the second area can tell them apart.
|
||||
#[test]
|
||||
fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
|
||||
let mut rsc = SenseRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
|
||||
// Two 1000px-tall scroll areas, stacked: the top half of the window
|
||||
// is the first, the bottom half the second. Each area's own handle is
|
||||
// taken as its chain is built (`with_id`, the same way the nested-axes
|
||||
// test above does it), since what is under test is `scrollable()`'s
|
||||
// real registration rather than a `ScrollArea` assembled by hand.
|
||||
let seen: [Rc<Cell<Option<WeakWidget<ScrollArea>>>>; 2] = Default::default();
|
||||
let half = |slot: &Rc<Cell<Option<WeakWidget<ScrollArea>>>>| {
|
||||
let record = slot.clone();
|
||||
rect(UiColor::WHITE)
|
||||
.height(Len::abs(1000.0))
|
||||
.scrollable(Axis::Y, Pin::Start)
|
||||
.with_id(move |_rsc, id| {
|
||||
record.set(Some(id));
|
||||
id
|
||||
})
|
||||
.height(Len::rel(0.5))
|
||||
};
|
||||
let root = (half(&seen[0]), half(&seen[1]))
|
||||
.span(Dir::DOWN)
|
||||
.add_strong(&mut rsc)
|
||||
.any();
|
||||
let (top_w, bottom_w) = (seen[0].get().unwrap(), seen[1].get().unwrap());
|
||||
|
||||
let win: Vec2 = (100.0, 200.0).into();
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((win.x, win.y));
|
||||
render.update(&root, &mut rsc);
|
||||
// The second frame is the first that knows how long the content is --
|
||||
// see `a_finger_drag_over_a_scroll_area_pans_it`.
|
||||
for w in [&top_w, &bottom_w] {
|
||||
rsc.ui.widgets.get_mut(w).unwrap().scroll(0.0);
|
||||
}
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let mut state = ();
|
||||
// Flick the top area and let go: it is left flinging, and -- because
|
||||
// the release goes through `run_sensors`' capture branch, which
|
||||
// returns before the loop that would have updated anybody's hover --
|
||||
// its sensor is left `On` with the pointer no longer on it. Both
|
||||
// halves of the real gesture, since both are what the bug needs.
|
||||
let base = Instant::now();
|
||||
let mut t = 0;
|
||||
let sample = |render: &mut UiRenderState,
|
||||
rsc: &mut SenseRsc,
|
||||
state: &mut (),
|
||||
y: f32,
|
||||
button: ActivationState,
|
||||
at_ms: u64| {
|
||||
let mut c = cursor_at((50.0, y).into());
|
||||
c.buttons.left = button;
|
||||
c.time = base + std::time::Duration::from_millis(at_ms);
|
||||
render.run_sensors(rsc, state, c, win);
|
||||
render.update(&root, rsc);
|
||||
};
|
||||
sample(
|
||||
&mut render,
|
||||
&mut rsc,
|
||||
&mut state,
|
||||
50.0,
|
||||
ActivationState::Start,
|
||||
t,
|
||||
);
|
||||
for y in [44.0, 32.0, 14.0] {
|
||||
t += 8;
|
||||
sample(&mut render, &mut rsc, &mut state, y, ActivationState::On, t);
|
||||
}
|
||||
t += 8;
|
||||
sample(
|
||||
&mut render,
|
||||
&mut rsc,
|
||||
&mut state,
|
||||
14.0,
|
||||
ActivationState::End,
|
||||
t,
|
||||
);
|
||||
assert!(
|
||||
rsc.ui.widgets.get(&top_w).unwrap().is_scrolling(),
|
||||
"the flick must leave the top area coasting -- the press below is \
|
||||
only dangerous while something is still moving",
|
||||
);
|
||||
let flung_to = rsc.ui.widgets.get(&top_w).unwrap().amt();
|
||||
|
||||
// Now press and drag in the *bottom* area: the top area's hover
|
||||
// decays to `End` on this very sample, which is the frame that used
|
||||
// to carry a `PressStart` to it.
|
||||
t += 8;
|
||||
sample(
|
||||
&mut render,
|
||||
&mut rsc,
|
||||
&mut state,
|
||||
150.0,
|
||||
ActivationState::Start,
|
||||
t,
|
||||
);
|
||||
t += 8;
|
||||
sample(
|
||||
&mut render,
|
||||
&mut rsc,
|
||||
&mut state,
|
||||
150.0 - (DRAG_SLOP + 40.0),
|
||||
ActivationState::On,
|
||||
t,
|
||||
);
|
||||
|
||||
let moved = rsc.ui.widgets.get(&bottom_w).unwrap().amt();
|
||||
assert!(
|
||||
(moved - 40.0).abs() < 0.01,
|
||||
"the area actually under the finger should have panned by the 40px \
|
||||
past the slop, got {moved}"
|
||||
);
|
||||
assert_eq!(
|
||||
rsc.ui.widgets.get(&top_w).unwrap().amt(),
|
||||
flung_to,
|
||||
"the area the pointer had left must not have seen the press at all -- \
|
||||
a catch would have stopped its fling on the touch-down"
|
||||
);
|
||||
assert_eq!(
|
||||
pointer_input(&mut rsc).holder(),
|
||||
Some(bottom_w.id()),
|
||||
"the gesture belongs to the widget under the finger",
|
||||
);
|
||||
}
|
||||
@@ -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<T> {
|
||||
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<State>`
|
||||
/// (`default/mod.rs`, `android/view.rs`), because a blanket impl over every
|
||||
/// `I: RscIdx<Rsc>` for every possible `Rsc` would conflict between crates.
|
||||
pub trait RscIdx<Rsc> {
|
||||
type Output;
|
||||
fn get(self, rsc: &Rsc) -> &Self::Output;
|
||||
fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output;
|
||||
}
|
||||
|
||||
impl<W: Widget, Rsc: UiRsc> RscIdx<Rsc> for WeakWidget<W> {
|
||||
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<T: 'static, Rsc: HasWidgetState> RscIdx<Rsc> for WeakState<T> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<Rsc> = SyncSender<Box<dyn TaskUpdate<Rsc>>>;
|
||||
pub type TaskMsgReceiver<Rsc> = SyncReceiver<Box<dyn TaskUpdate<Rsc>>>;
|
||||
@@ -23,7 +33,7 @@ impl<F: FnOnce(&mut Rsc::State, &mut Rsc) + Send, Rsc: HasState> TaskUpdate<Rsc>
|
||||
|
||||
pub struct Tasks<Rsc: HasState> {
|
||||
start: AsyncSender<BoxTask>,
|
||||
window: Arc<Window>,
|
||||
redraw: Arc<dyn RequestRedraw>,
|
||||
msg_send: SyncSender<Box<dyn TaskUpdate<Rsc>>>,
|
||||
}
|
||||
|
||||
@@ -45,7 +55,7 @@ impl<Rsc: HasState + 'static> TaskCtx<Rsc> {
|
||||
type BoxTask = Pin<Box<dyn Future<Output = ()> + Send>>;
|
||||
|
||||
impl<Rsc: HasState> Tasks<Rsc> {
|
||||
pub fn init(window: Arc<Window>) -> (Self, TaskMsgReceiver<Rsc>) {
|
||||
pub fn init(redraw: Arc<dyn RequestRedraw>) -> (Self, TaskMsgReceiver<Rsc>) {
|
||||
let (start, start_recv) = async_channel();
|
||||
let (msgs, msgs_recv) = sync_channel();
|
||||
std::thread::spawn(|| {
|
||||
@@ -56,21 +66,33 @@ impl<Rsc: HasState> Tasks<Rsc> {
|
||||
Self {
|
||||
start,
|
||||
msg_send: msgs,
|
||||
window,
|
||||
redraw,
|
||||
},
|
||||
msgs_recv,
|
||||
)
|
||||
}
|
||||
|
||||
/// The same redraw handle `spawn`'s wrapper calls once, after a whole
|
||||
/// task's future completes -- exposed so a caller running its own
|
||||
/// longer-lived loop *inside* a spawned task (a live SSE follow, here)
|
||||
/// can ask for a frame after each `TaskCtx::update`, not just at the
|
||||
/// end. Without this a caller has no way to get a redraw mid-stream,
|
||||
/// which is exactly the gap `iris/desktop-app`'s `app.rs` module doc
|
||||
/// names for why it uses winit's `Proxy` instead of `Tasks` -- Android
|
||||
/// has no `Proxy`, so this is what closes the same gap there.
|
||||
pub fn redraw_handle(&self) -> Arc<dyn RequestRedraw> {
|
||||
self.redraw.clone()
|
||||
}
|
||||
|
||||
pub fn spawn<F: AsyncFnOnce(TaskCtx<Rsc>) + 'static + std::marker::Send>(&mut self, task: F)
|
||||
where
|
||||
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();
|
||||
}));
|
||||
}
|
||||
}
|
||||
+11
-8
@@ -6,16 +6,19 @@ pub struct Image {
|
||||
}
|
||||
|
||||
impl Widget for Image {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
painter.texture(&self.handle);
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
// Drawn at its own natural size, anchored top-left of whatever it
|
||||
// was offered, not stretched to fill it -- its primitive is
|
||||
// independent of the offered region, matching `is_size_independent`
|
||||
// below. A caller that wants it placed differently wraps it (e.g.
|
||||
// `.center()`, `.align(...)`).
|
||||
let size = self.handle.size();
|
||||
painter.texture_within(&self.handle, size.align(Align::TOP_LEFT));
|
||||
Size::abs(size)
|
||||
}
|
||||
|
||||
fn desired_width(&mut self, _: &mut SizeCtx) -> Len {
|
||||
Len::abs(self.handle.size().x)
|
||||
}
|
||||
|
||||
fn desired_height(&mut self, _: &mut SizeCtx) -> Len {
|
||||
Len::abs(self.handle.size().y)
|
||||
fn is_size_independent(&self) -> bool {
|
||||
true // a decoded image's primitive never depends on the region it is offered
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-11
@@ -1,20 +1,36 @@
|
||||
use crate::prelude::*;
|
||||
|
||||
/// Clips `inner` -- and everything below it -- to a shape.
|
||||
///
|
||||
/// The shape is a **primitive**, never a rectangle or a radius stored
|
||||
/// here: with `shape`, the widget named there is drawn behind `inner`
|
||||
/// filling the same box and the clip is its first primitive, so a rounded
|
||||
/// container's corner and the corner its content is cut to are the same
|
||||
/// arithmetic and cannot fall out of step. Without one, this writes an
|
||||
/// undrawn rect at its own region, which is the plain "clip to my box"
|
||||
/// every list and scroll area wants. See docs/LAYOUT.md's "Masks with a
|
||||
/// shape".
|
||||
pub struct Masked {
|
||||
/// The widget whose first primitive is the clip, drawn behind
|
||||
/// `inner`, or `None` for this widget's own box.
|
||||
pub shape: Option<StrongWidget>,
|
||||
pub inner: StrongWidget,
|
||||
}
|
||||
|
||||
impl Widget for Masked {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
painter.set_mask(painter.region());
|
||||
painter.widget(&self.inner);
|
||||
}
|
||||
|
||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
ctx.width(&self.inner)
|
||||
}
|
||||
|
||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
ctx.height(&self.inner)
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
match &self.shape {
|
||||
// Layered the way `Stack` layers a background under its
|
||||
// content, and for the same reason: within one layer the draw
|
||||
// order is undefined once anything has been freed.
|
||||
Some(shape) => {
|
||||
painter.child_layer();
|
||||
painter.widget(shape);
|
||||
painter.set_mask_to_widget(shape);
|
||||
painter.next_layer();
|
||||
}
|
||||
None => painter.set_mask(painter.region()),
|
||||
}
|
||||
painter.widget(&self.inner)
|
||||
}
|
||||
}
|
||||
@@ -6,30 +6,31 @@ pub struct Aligned {
|
||||
}
|
||||
|
||||
impl Widget for Aligned {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
// Draw once at the whole region this widget was offered to learn
|
||||
// the child's real size -- this placement is provisional and
|
||||
// corrected below without a second draw. `painter.widget` (not
|
||||
// `widget_within(..., painter.region())`) is what "my whole,
|
||||
// already-resolved region, unmodified" means: `widget_within`
|
||||
// composes its argument as a *local*, `UiRegion::FULL`-relative
|
||||
// box against `painter.region()`, so handing it the
|
||||
// already-resolved region double-applies that composition and is
|
||||
// wrong for any widget nested below the root.
|
||||
let used = painter.widget(&self.inner);
|
||||
let density = painter.density();
|
||||
let region = match self.align.tuple() {
|
||||
(Some(x), Some(y)) => painter
|
||||
.size(&self.inner)
|
||||
.to_uivec2()
|
||||
.align(RegionAlign { x, y }),
|
||||
(Some(x), Some(y)) => used.to_uivec2(density).align(RegionAlign { x, y }),
|
||||
(Some(x), None) => {
|
||||
let x = painter.size_ctx().width(&self.inner).apply_rest().align(x);
|
||||
let x = used.x.apply_rest(density).align(x);
|
||||
UiRegion::new(x, UiSpan::FULL)
|
||||
}
|
||||
(None, Some(y)) => {
|
||||
let y = painter.size_ctx().height(&self.inner).apply_rest().align(y);
|
||||
let y = used.y.apply_rest(density).align(y);
|
||||
UiRegion::new(UiSpan::FULL, y)
|
||||
}
|
||||
(None, None) => UiRegion::FULL,
|
||||
};
|
||||
painter.widget_within(&self.inner, region);
|
||||
}
|
||||
|
||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
ctx.width(&self.inner)
|
||||
}
|
||||
|
||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
ctx.height(&self.inner)
|
||||
painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
|
||||
used
|
||||
}
|
||||
}
|
||||
@@ -6,18 +6,10 @@ pub struct LayerOffset {
|
||||
}
|
||||
|
||||
impl Widget for LayerOffset {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
for _ in 0..self.offset {
|
||||
painter.next_layer();
|
||||
}
|
||||
painter.widget(&self.inner);
|
||||
}
|
||||
|
||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
ctx.width(&self.inner)
|
||||
}
|
||||
|
||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
ctx.height(&self.inner)
|
||||
painter.widget(&self.inner)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -7,42 +7,57 @@ pub struct MaxSize {
|
||||
}
|
||||
|
||||
impl MaxSize {
|
||||
fn apply_to_outer(&self, ctx: &mut SizeCtx) {
|
||||
if let Some(x) = self.x {
|
||||
ctx.outer.x.select_len(x.apply_rest());
|
||||
/// Caps a reported length at `max`, comparing in pixels since `Len`'s
|
||||
/// rel/abs/rest components are not otherwise comparable.
|
||||
fn clamp(len: Len, max: Option<Len>, output: f32, density: f32) -> Len {
|
||||
let Some(max) = max else {
|
||||
return len;
|
||||
};
|
||||
let len_px = len.apply_rest(density).to_abs(output);
|
||||
let max_px = max.apply_rest(density).to_abs(output);
|
||||
// `fold_dp`, not the caller's `max` as written: a reported `Len`
|
||||
// may not carry an unresolved `dp` -- see `Len::fold_dp` for the
|
||||
// collapsed composer bar this caused.
|
||||
if len_px > max_px {
|
||||
max.fold_dp(density)
|
||||
} else {
|
||||
len
|
||||
}
|
||||
if let Some(y) = self.y {
|
||||
ctx.outer.y.select_len(y.apply_rest());
|
||||
}
|
||||
|
||||
/// The span (in this widget's own local, `UiRegion::FULL`-relative
|
||||
/// terms) to actually offer the child: unconstrained if it already fits
|
||||
/// within `max`, or a box of exactly `max`, anchored at this axis's
|
||||
/// start, if it does not. Needed so the child is never painted bigger
|
||||
/// than the size this widget reports for it -- see the identical
|
||||
/// requirement noted on `Sized::draw`.
|
||||
fn clamp_region(offered_px: f32, max: Option<Len>, output: f32, density: f32) -> UiSpan {
|
||||
let Some(max) = max else {
|
||||
return UiSpan::FULL;
|
||||
};
|
||||
let max_scalar = max.apply_rest(density);
|
||||
let max_px = max_scalar.to_abs(output);
|
||||
if offered_px > max_px {
|
||||
max_scalar.align(AxisAlign::Neg)
|
||||
} else {
|
||||
UiSpan::FULL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for MaxSize {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
painter.widget(&self.inner);
|
||||
}
|
||||
|
||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
self.apply_to_outer(ctx);
|
||||
let width = ctx.width(&self.inner);
|
||||
if let Some(x) = self.x {
|
||||
let width_px = width.apply_rest().to_abs(ctx.output_size().x);
|
||||
let x_px = x.apply_rest().to_abs(ctx.output_size().x);
|
||||
if width_px > x_px { x } else { width }
|
||||
} else {
|
||||
width
|
||||
}
|
||||
}
|
||||
|
||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
self.apply_to_outer(ctx);
|
||||
let height = ctx.height(&self.inner);
|
||||
if let Some(y) = self.y {
|
||||
let height_px = height.apply_rest().to_abs(ctx.output_size().y);
|
||||
let y_px = y.apply_rest().to_abs(ctx.output_size().y);
|
||||
if height_px > y_px { y } else { height }
|
||||
} else {
|
||||
height
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
let output = painter.output_size();
|
||||
let density = painter.density();
|
||||
let offered = painter.px_size();
|
||||
let region = UiRegion {
|
||||
x: Self::clamp_region(offered.x, self.x, output.x, density),
|
||||
y: Self::clamp_region(offered.y, self.y, output.y, density),
|
||||
};
|
||||
let used = painter.widget_within(&self.inner, region);
|
||||
Size {
|
||||
x: Self::clamp(used.x, self.x, output.x, density),
|
||||
y: Self::clamp(used.y, self.y, output.y, density),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
mod align;
|
||||
mod layer;
|
||||
mod lazy_span;
|
||||
mod max_size;
|
||||
mod offset;
|
||||
mod pad;
|
||||
mod scroll;
|
||||
mod scroll_area;
|
||||
mod scrollable;
|
||||
mod sized;
|
||||
mod span;
|
||||
mod stack;
|
||||
|
||||
pub use align::*;
|
||||
pub use layer::*;
|
||||
pub use lazy_span::*;
|
||||
pub use max_size::*;
|
||||
pub use offset::*;
|
||||
pub use pad::*;
|
||||
pub use scroll::*;
|
||||
pub use scroll_area::*;
|
||||
pub use scrollable::*;
|
||||
pub use sized::*;
|
||||
pub use span::*;
|
||||
pub use stack::*;
|
||||
@@ -6,16 +6,8 @@ pub struct Offset {
|
||||
}
|
||||
|
||||
impl Widget for Offset {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
let region = UiRegion::FULL.offset(self.amt);
|
||||
painter.widget_within(&self.inner, region);
|
||||
}
|
||||
|
||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
ctx.width(&self.inner)
|
||||
}
|
||||
|
||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
ctx.height(&self.inner)
|
||||
painter.widget_within(&self.inner, region)
|
||||
}
|
||||
}
|
||||
+62
-63
@@ -6,48 +6,43 @@ pub struct Pad {
|
||||
}
|
||||
|
||||
impl Widget for Pad {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
painter.widget_within(&self.inner, self.padding.region());
|
||||
}
|
||||
|
||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
let width = self.padding.left + self.padding.right;
|
||||
let height = self.padding.top + self.padding.bottom;
|
||||
ctx.outer.x.abs -= width;
|
||||
ctx.outer.y.abs -= height;
|
||||
let mut size = ctx.width(&self.inner);
|
||||
size.abs += width;
|
||||
size
|
||||
}
|
||||
|
||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
let width = self.padding.left + self.padding.right;
|
||||
let height = self.padding.top + self.padding.bottom;
|
||||
ctx.outer.x.abs -= width;
|
||||
ctx.outer.y.abs -= height;
|
||||
let mut size = ctx.height(&self.inner);
|
||||
size.abs += height;
|
||||
size
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
let density = painter.density();
|
||||
let used = painter.widget_within(&self.inner, self.padding.region(density));
|
||||
let width =
|
||||
self.padding.left.apply_rest(density).abs + self.padding.right.apply_rest(density).abs;
|
||||
let height =
|
||||
self.padding.top.apply_rest(density).abs + self.padding.bottom.apply_rest(density).abs;
|
||||
Size {
|
||||
x: used.x + Len::abs(width),
|
||||
y: used.y + Len::abs(height),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Each side is a `Len`, not a bare `f32`, so `.pad(dp(10))` resolves
|
||||
/// against the display's density the same way any other size does -- see
|
||||
/// `Len::dp`'s field doc. `.pad(10)` (a bare number) still works via
|
||||
/// `From<T: UiNum>` below, unchanged: it becomes an `abs` (physical-pixel)
|
||||
/// `Len`, exactly as a bare number always has meant elsewhere in this
|
||||
/// crate.
|
||||
pub struct Padding {
|
||||
pub left: f32,
|
||||
pub right: f32,
|
||||
pub top: f32,
|
||||
pub bottom: f32,
|
||||
pub left: Len,
|
||||
pub right: Len,
|
||||
pub top: Len,
|
||||
pub bottom: Len,
|
||||
}
|
||||
|
||||
impl Padding {
|
||||
pub const ZERO: Self = Self {
|
||||
left: 0.0,
|
||||
right: 0.0,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
left: Len::ZERO,
|
||||
right: Len::ZERO,
|
||||
top: Len::ZERO,
|
||||
bottom: Len::ZERO,
|
||||
};
|
||||
|
||||
pub fn uniform(amt: impl UiNum) -> Self {
|
||||
let amt = amt.to_f32();
|
||||
pub fn uniform(amt: impl Into<Len>) -> Self {
|
||||
let amt = amt.into();
|
||||
Self {
|
||||
left: amt,
|
||||
right: amt,
|
||||
@@ -55,80 +50,84 @@ impl Padding {
|
||||
bottom: amt,
|
||||
}
|
||||
}
|
||||
pub fn region(&self) -> UiRegion {
|
||||
pub fn region(&self, density: f32) -> UiRegion {
|
||||
let mut region = UiRegion::FULL;
|
||||
region.x.start.abs += self.left;
|
||||
region.y.start.abs += self.top;
|
||||
region.x.end.abs -= self.right;
|
||||
region.y.end.abs -= self.bottom;
|
||||
region.x.start.abs += self.left.apply_rest(density).abs;
|
||||
region.y.start.abs += self.top.apply_rest(density).abs;
|
||||
region.x.end.abs -= self.right.apply_rest(density).abs;
|
||||
region.y.end.abs -= self.bottom.apply_rest(density).abs;
|
||||
region
|
||||
}
|
||||
pub fn x(amt: impl UiNum) -> Self {
|
||||
let amt = amt.to_f32();
|
||||
pub fn x(amt: impl Into<Len>) -> Self {
|
||||
let amt = amt.into();
|
||||
Self {
|
||||
left: amt,
|
||||
right: amt,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
top: Len::ZERO,
|
||||
bottom: Len::ZERO,
|
||||
}
|
||||
}
|
||||
pub fn y(amt: impl UiNum) -> Self {
|
||||
let amt = amt.to_f32();
|
||||
pub fn y(amt: impl Into<Len>) -> Self {
|
||||
let amt = amt.into();
|
||||
Self {
|
||||
left: 0.0,
|
||||
right: 0.0,
|
||||
left: Len::ZERO,
|
||||
right: Len::ZERO,
|
||||
top: amt,
|
||||
bottom: amt,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn top(amt: impl UiNum) -> Self {
|
||||
pub fn top(amt: impl Into<Len>) -> Self {
|
||||
let mut s = Self::ZERO;
|
||||
s.top = amt.to_f32();
|
||||
s.top = amt.into();
|
||||
s
|
||||
}
|
||||
|
||||
pub fn bottom(amt: impl UiNum) -> Self {
|
||||
pub fn bottom(amt: impl Into<Len>) -> Self {
|
||||
let mut s = Self::ZERO;
|
||||
s.bottom = amt.to_f32();
|
||||
s.bottom = amt.into();
|
||||
s
|
||||
}
|
||||
|
||||
pub fn left(amt: impl UiNum) -> Self {
|
||||
pub fn left(amt: impl Into<Len>) -> Self {
|
||||
let mut s = Self::ZERO;
|
||||
s.left = amt.to_f32();
|
||||
s.left = amt.into();
|
||||
s
|
||||
}
|
||||
|
||||
pub fn right(amt: impl UiNum) -> Self {
|
||||
pub fn right(amt: impl Into<Len>) -> Self {
|
||||
let mut s = Self::ZERO;
|
||||
s.right = amt.to_f32();
|
||||
s.right = amt.into();
|
||||
s
|
||||
}
|
||||
|
||||
pub fn with_top(mut self, amt: impl UiNum) -> Self {
|
||||
self.top = amt.to_f32();
|
||||
pub fn with_top(mut self, amt: impl Into<Len>) -> Self {
|
||||
self.top = amt.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_bottom(mut self, amt: impl UiNum) -> Self {
|
||||
self.bottom = amt.to_f32();
|
||||
pub fn with_bottom(mut self, amt: impl Into<Len>) -> Self {
|
||||
self.bottom = amt.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_left(mut self, amt: impl UiNum) -> Self {
|
||||
self.left = amt.to_f32();
|
||||
pub fn with_left(mut self, amt: impl Into<Len>) -> Self {
|
||||
self.left = amt.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_right(mut self, amt: impl UiNum) -> Self {
|
||||
self.right = amt.to_f32();
|
||||
pub fn with_right(mut self, amt: impl Into<Len>) -> Self {
|
||||
self.right = amt.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: UiNum> From<T> for Padding {
|
||||
/// Covers both a bare number (`.pad(8)`, via `Len`'s own `From<N: UiNum>`
|
||||
/// blanket -- an `abs`/physical-pixel `Len`) and a `Len` directly
|
||||
/// (`.pad(dp(10))`) with the one impl, since `Len: Into<Len>` is the
|
||||
/// reflexive case of the same bound.
|
||||
impl<T: Into<Len>> From<T> for Padding {
|
||||
fn from(amt: T) -> Self {
|
||||
Self::uniform(amt.to_f32())
|
||||
Self::uniform(amt.into())
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
use crate::prelude::*;
|
||||
|
||||
pub struct Scroll {
|
||||
inner: StrongWidget,
|
||||
axis: Axis,
|
||||
amt: f32,
|
||||
snap_end: bool,
|
||||
container_len: f32,
|
||||
content_len: f32,
|
||||
}
|
||||
|
||||
impl Widget for Scroll {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
let output_len = painter.output_size().axis(self.axis);
|
||||
let container_len = painter.region().axis(self.axis).len();
|
||||
let content_len = painter
|
||||
.len_axis(&self.inner, self.axis)
|
||||
.apply_rest()
|
||||
.within_len(container_len)
|
||||
.to_abs(output_len);
|
||||
self.container_len = container_len.to_abs(output_len);
|
||||
self.content_len = content_len;
|
||||
|
||||
if self.snap_end {
|
||||
self.amt = self.content_len - self.container_len;
|
||||
}
|
||||
self.update_amt();
|
||||
|
||||
let mut region = UiRegion::FULL.offset(Vec2::from_axis(self.axis, -self.amt, 0.0));
|
||||
region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(self.content_len);
|
||||
painter.widget_within(&self.inner, region);
|
||||
}
|
||||
|
||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
ctx.width(&self.inner)
|
||||
}
|
||||
|
||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
ctx.height(&self.inner)
|
||||
}
|
||||
}
|
||||
|
||||
impl Scroll {
|
||||
pub fn new(inner: StrongWidget, axis: Axis) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
axis,
|
||||
amt: 0.0,
|
||||
snap_end: true,
|
||||
container_len: 0.0,
|
||||
content_len: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_amt(&mut self) {
|
||||
self.amt = self.amt.max(0.0);
|
||||
let len = (self.content_len - self.container_len).max(0.0);
|
||||
self.amt = self.amt.min(len);
|
||||
self.snap_end = self.amt == len;
|
||||
}
|
||||
|
||||
pub fn scroll(&mut self, amt: f32) {
|
||||
self.amt -= amt;
|
||||
self.update_amt();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
//! `ScrollArea`: a fixed child, slid about by a [`ScrollController`].
|
||||
//!
|
||||
//! **`docs/SCROLL.md` is the overview** -- the one sign convention, what
|
||||
//! `amt` means, and how this differs from a `LazySpan`, which scrolls
|
||||
//! itself. Read it first; this file is the detail.
|
||||
|
||||
use crate::prelude::*;
|
||||
use std::time::Instant;
|
||||
|
||||
/// A scrolling view over a child that is a fixed lump: it is measured
|
||||
/// whole and then moved, which is what makes a scroll tick an O(1) move of
|
||||
/// one subtree rather than a redraw.
|
||||
///
|
||||
/// **"Area" because it only scrolls a predefined one** (Iris, 2026-09-08):
|
||||
/// a child that lays out lazily cannot be measured whole or moved as a
|
||||
/// lump, and virtualising it inside one of these would never update which
|
||||
/// rows it shows, since a scroll tick offers a same-size moved region and
|
||||
/// `draw_inner` never re-enters the child. That case is `LazySpan`, which
|
||||
/// owns a controller of its own instead of being wrapped in one of these.
|
||||
pub struct ScrollArea {
|
||||
inner: StrongWidget,
|
||||
/// The position, the gesture, the fling and the pin -- everything
|
||||
/// about scrolling that is not this widget's own layout, shared with
|
||||
/// `LazySpan` rather than reimplemented beside it.
|
||||
ctl: ScrollController,
|
||||
container_len: f32,
|
||||
/// How long the content is along the axis, as of the last draw --
|
||||
/// `None` until this widget has drawn once.
|
||||
///
|
||||
/// An `Option` rather than a `0.0` that stands in for both, because
|
||||
/// the two answers led somewhere different and the code could not tell
|
||||
/// them apart: on the first frame the clamp computed a scroll range of
|
||||
/// zero, concluded from `amt == range` that the area was sitting at
|
||||
/// its end, and pinned it -- so the next frame, now knowing the real
|
||||
/// length, jumped to it. A code fence therefore opened at the end of
|
||||
/// its longest line, mid-word (`iris/run-headless.sh phone`,
|
||||
/// 2026-09-08).
|
||||
content_len: Option<f32>,
|
||||
}
|
||||
|
||||
impl Scrollable for ScrollArea {
|
||||
fn controller(&self) -> &ScrollController {
|
||||
&self.ctl
|
||||
}
|
||||
|
||||
fn controller_mut(&mut self) -> &mut ScrollController {
|
||||
&mut self.ctl
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for ScrollArea {
|
||||
/// A scroll area animates exactly one thing, its fling. The
|
||||
/// registration that makes this run is `UiData::animate`, which
|
||||
/// `WidgetLike::scrollable`'s own drag handler calls the frame a
|
||||
/// release starts one.
|
||||
fn tick(&mut self, now: Instant) -> bool {
|
||||
self.tick_fling(now)
|
||||
}
|
||||
|
||||
/// Measure, then place -- the same idiom `LazySpan` uses, for the same
|
||||
/// reason: nothing drawn may depend on a length measured last frame.
|
||||
///
|
||||
/// **The child is drawn twice, and only the second decides anything.**
|
||||
/// The first is handed last frame's length as a *hint*, and it exists
|
||||
/// only so that the usual case, where the content's length did not
|
||||
/// change, offers the same region twice: `draw_inner` then makes the
|
||||
/// first call an O(1) `mov` and returns at the first line of the
|
||||
/// second. A frame on which the content did grow or shrink pays one
|
||||
/// real extra draw, and that is a frame on which the content was being
|
||||
/// redrawn anyway.
|
||||
///
|
||||
/// The alternative -- place against the hint and let the next frame
|
||||
/// fix it -- is what Iris found on her phone (2026-09-08): every
|
||||
/// newline typed into the composer drew the field in a box one line
|
||||
/// short of its text, and since that text is centred in its box it
|
||||
/// hung half a line past each end. There was no next frame: nothing
|
||||
/// dirtied that subtree again, so the stale placement was the last one
|
||||
/// drawn, until the keyboard closed and its inset rewrite forced a
|
||||
/// redraw ("it fixes itself"). **Layout is a pure function of the
|
||||
/// state, not of how many frames have been drawn** (Iris, 2026-09-08)
|
||||
/// -- a correction that needs a second frame is a frame drawn wrong.
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
// Every length here is resolved against the box this widget was
|
||||
// **offered** (`px_size`), never `output_size`: a scroll area is
|
||||
// routinely smaller than the window -- the composer's field is
|
||||
// capped at six lines by a `MaxSize` around it -- and measuring
|
||||
// the window instead would make the pan range, and so where the
|
||||
// content sits, a function of the screen rather than of the box.
|
||||
let axis = self.ctl.axis();
|
||||
let container_len = painter.px_size().axis(axis);
|
||||
self.container_len = container_len;
|
||||
// Learned from the frame rather than passed in: a fling's
|
||||
// deceleration is a physical quantity and needs the real display
|
||||
// density, and `draw` is where this widget meets the only thing
|
||||
// that knows it.
|
||||
self.ctl.set_density(painter.density());
|
||||
|
||||
// Where the delta asked for since the last frame puts the content.
|
||||
// Already inside the range the previous frame published, so it is
|
||||
// the position to *measure* against; the clamp below is what the
|
||||
// length just measured has to say about it.
|
||||
let delta = self.ctl.take_delta();
|
||||
let travelled = self.ctl.amt() - delta;
|
||||
self.ctl.set_amt(travelled);
|
||||
|
||||
// The container's own length stands in as the hint until anything
|
||||
// has been measured: a zero-length region on the first frame would
|
||||
// place the child's primitives against a box of no size.
|
||||
let hint = self.content_len.unwrap_or(container_len);
|
||||
let used = painter.widget_within(&self.inner, self.child_region(hint));
|
||||
|
||||
// A child reporting `rel` means "this fraction of what I was
|
||||
// offered", and what it was offered is this scroll area -- so the
|
||||
// container, again, is what that resolves against.
|
||||
let measured = used
|
||||
.axis(axis)
|
||||
.apply_rest(painter.density())
|
||||
.to_abs(container_len);
|
||||
self.content_len = Some(measured);
|
||||
let range = (measured - container_len).max(0.0);
|
||||
|
||||
// The end-pin, and then the clamp, against the length just
|
||||
// measured. Deliberately not also run before the measuring draw
|
||||
// above -- clamping against the hint would let a stale length
|
||||
// reduce `amt` in a way this pass cannot undo, and then where the
|
||||
// content sits would depend on the previous frame after all.
|
||||
//
|
||||
// Only a frame with no delta of its own re-pins: the pin means
|
||||
// "stay flush with the end as the content grows", and a reader who
|
||||
// just scrolled away from that end has said otherwise. (A delta
|
||||
// cannot be moving *toward* the end here -- the travel published
|
||||
// below is zero that way while pinned, so `take_delta` has already
|
||||
// clipped it.)
|
||||
let amt = if self.ctl.pinned_to_end() && delta == 0.0 {
|
||||
range
|
||||
} else {
|
||||
travelled.clamp(0.0, range)
|
||||
};
|
||||
self.ctl.set_amt(amt);
|
||||
self.ctl.set_pinned_to_end(amt >= range);
|
||||
self.ctl.set_travel(Travel {
|
||||
back: amt,
|
||||
fwd: range - amt,
|
||||
});
|
||||
|
||||
// The **content's** size, not the container's. A parent that can
|
||||
// grow (the composer's bar) should hug the text until its own cap
|
||||
// stops it, and reporting the container instead would make this
|
||||
// widget's answer a function of the answer -- the bar is sized
|
||||
// from what is reported here, so it collapses to nothing and never
|
||||
// recovers. What keeps the content inside the offered box is the
|
||||
// mask a caller puts around it (`.scrollable(..).masked()`), not
|
||||
// this number.
|
||||
painter.widget_within(&self.inner, self.child_region(measured))
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollArea {
|
||||
/// `pin` says which end this area opens at and clings to -- see
|
||||
/// [`Pin`], and `WidgetLike::scrollable`, which is how one of these is
|
||||
/// normally built.
|
||||
pub fn new(inner: StrongWidget, axis: Axis, pin: Pin) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
// A fixed child is laid out from the box's negative edge
|
||||
// onward, always, so the end of its content is the positive
|
||||
// one -- which is what makes `Pin::End` and `Pin::Pos` the
|
||||
// same pin here and different ones in a reversed `LazySpan`.
|
||||
ctl: ScrollController::new(Dir::new(axis, Sign::Pos), pin),
|
||||
container_len: 0.0,
|
||||
content_len: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the child sits for a given content length: a box that long
|
||||
/// along the scroll axis, pulled back by `amt`. The length is taken as
|
||||
/// a parameter rather than read from `content_len`, because `draw`
|
||||
/// places twice -- once against last frame's length and once against
|
||||
/// the one it has just measured -- and the two must be the same
|
||||
/// arithmetic.
|
||||
fn child_region(&self, content_len: f32) -> UiRegion {
|
||||
let axis = self.ctl.axis();
|
||||
let mut region = UiRegion::FULL;
|
||||
region.axis_mut(axis).end = region.axis(axis).start.offset(content_len);
|
||||
region.offset(Vec2::from_axis(axis, -self.ctl.amt(), 0.0))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layout_tests::TestRsc;
|
||||
use crate::sense::{CursorButton, DRAG_SLOP, PointerRequests};
|
||||
use iris_core::UiData;
|
||||
use std::time::Duration;
|
||||
|
||||
/// A scroll area with 1000px of content in a 100px box, drawn once and
|
||||
/// settled somewhere in the middle so a drag has room in both
|
||||
/// directions.
|
||||
///
|
||||
/// Built and rendered for real rather than assembled field by field,
|
||||
/// because a delta is spent in `draw` now (the controller banks it, and
|
||||
/// only a layout knows where the content ends) -- so a test that never
|
||||
/// draws would watch `amt` never move and read that as a broken
|
||||
/// gesture.
|
||||
fn area() -> (Fixture, WidgetId) {
|
||||
area_on(Axis::Y)
|
||||
}
|
||||
|
||||
/// The same fixture on either axis -- a code fence pans sideways
|
||||
/// through one of these exactly as a field pans down, and the pair of
|
||||
/// them is what caught a fling that only worked vertically.
|
||||
fn area_on(axis: Axis) -> (Fixture, WidgetId) {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
|
||||
let id = fill.id();
|
||||
let long = Some(Len::abs(1000.0));
|
||||
let tall = rsc.ui.widgets.add_strong(Sized {
|
||||
inner: fill,
|
||||
x: (axis == Axis::X).then_some(long).flatten(),
|
||||
y: (axis == Axis::Y).then_some(long).flatten(),
|
||||
});
|
||||
let area = rsc
|
||||
.ui
|
||||
.widgets
|
||||
.add_strong(ScrollArea::new(tall.any(), axis, Pin::Start));
|
||||
let weak = area.weak();
|
||||
let root = area.any();
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 100.0));
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let mut fixture = Fixture {
|
||||
rsc,
|
||||
area: weak,
|
||||
root,
|
||||
render,
|
||||
};
|
||||
// 400px in, which is the middle of the 900px of travel this
|
||||
// content has.
|
||||
fixture.get().scroll(-400.0);
|
||||
fixture.draw();
|
||||
assert!((fixture.amt() - 400.0).abs() < 0.01);
|
||||
(fixture, id)
|
||||
}
|
||||
|
||||
/// The area under test with everything needed to draw it -- the drag
|
||||
/// tests all do the same three things (reach the widget, draw, read
|
||||
/// `amt`) and each of the three is a line of arena plumbing.
|
||||
struct Fixture {
|
||||
rsc: TestRsc,
|
||||
area: WeakWidget<ScrollArea>,
|
||||
root: StrongWidget,
|
||||
render: UiRenderState,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn get(&mut self) -> &mut ScrollArea {
|
||||
self.rsc.ui.widgets.get_mut(&self.area).unwrap()
|
||||
}
|
||||
|
||||
fn draw(&mut self) {
|
||||
self.render.update(&self.root, &mut self.rsc);
|
||||
}
|
||||
|
||||
fn amt(&self) -> f32 {
|
||||
self.rsc.ui.widgets.get(&self.area).unwrap().amt()
|
||||
}
|
||||
|
||||
/// One frame of a fling, the way `UiData::tick_animations` drives
|
||||
/// it: tick, then draw. Answers whether it is still going.
|
||||
fn fling_frame(&mut self, now: Instant) -> bool {
|
||||
let still = self.get().tick(now);
|
||||
self.draw();
|
||||
still
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame of a touch gesture, followed by the draw that spends it.
|
||||
fn press(f: &mut Fixture, id: WidgetId, sense: CursorSense, y: f32, t: Instant) {
|
||||
drag(f, id, sense, Vec2::new(0.0, y), t);
|
||||
}
|
||||
|
||||
/// The same, for a gesture whose position is not on the Y axis.
|
||||
fn drag(f: &mut Fixture, id: WidgetId, sense: CursorSense, pos: Vec2, t: Instant) {
|
||||
let pointer = PointerRequests::default();
|
||||
let flung = f.get().drag(&pointer, id, sense, pos, t);
|
||||
// What `WidgetLike::scrollable`'s own handler does with the
|
||||
// answer, and the half a fling does not move without.
|
||||
if flung {
|
||||
let id = f.area.id();
|
||||
f.rsc.ui.animate(id);
|
||||
}
|
||||
f.draw();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vertical_finger_drag_pans_the_content_with_the_finger() {
|
||||
let (mut f, id) = area();
|
||||
let t = Instant::now();
|
||||
press(
|
||||
&mut f,
|
||||
id,
|
||||
CursorSense::PressStart(CursorButton::Left),
|
||||
0.0,
|
||||
t,
|
||||
);
|
||||
// Finger down by well past the slop: the content follows it down,
|
||||
// which for this widget means *less* `amt`.
|
||||
press(
|
||||
&mut f,
|
||||
id,
|
||||
CursorSense::Pressing(CursorButton::Left),
|
||||
DRAG_SLOP + 30.0,
|
||||
t + Duration::from_millis(20),
|
||||
);
|
||||
assert!(
|
||||
(f.amt() - 370.0).abs() < 0.01,
|
||||
"expected the 30px past the slop to be applied downward, got amt={}",
|
||||
f.amt()
|
||||
);
|
||||
// ...and the next frame's motion is a plain per-frame delta.
|
||||
press(
|
||||
&mut f,
|
||||
id,
|
||||
CursorSense::Pressing(CursorButton::Left),
|
||||
DRAG_SLOP + 50.0,
|
||||
t + Duration::from_millis(40),
|
||||
);
|
||||
assert!((f.amt() - 350.0).abs() < 0.01, "amt={}", f.amt());
|
||||
}
|
||||
|
||||
/// The half the change had no reason to touch: a press that never
|
||||
/// leaves the slop is a tap, and must move nothing at all -- otherwise
|
||||
/// every tap on a scrollable field nudges its text.
|
||||
#[test]
|
||||
fn a_press_that_stays_inside_the_slop_does_not_scroll() {
|
||||
let (mut f, id) = area();
|
||||
let t = Instant::now();
|
||||
press(
|
||||
&mut f,
|
||||
id,
|
||||
CursorSense::PressStart(CursorButton::Left),
|
||||
0.0,
|
||||
t,
|
||||
);
|
||||
for (i, y) in [1.0, -2.0, DRAG_SLOP - 0.5].into_iter().enumerate() {
|
||||
press(
|
||||
&mut f,
|
||||
id,
|
||||
CursorSense::Pressing(CursorButton::Left),
|
||||
y,
|
||||
t + Duration::from_millis(10 * (i as u64 + 1)),
|
||||
);
|
||||
}
|
||||
press(
|
||||
&mut f,
|
||||
id,
|
||||
CursorSense::PressEnd(CursorButton::Left),
|
||||
DRAG_SLOP - 0.5,
|
||||
t + Duration::from_millis(50),
|
||||
);
|
||||
assert!(
|
||||
(f.amt() - 400.0).abs() < 0.01,
|
||||
"a tap scrolled: amt={}",
|
||||
f.amt()
|
||||
);
|
||||
}
|
||||
|
||||
/// A horizontal drag is not this widget's gesture: it must stay put
|
||||
/// rather than pick up the vertical noise in a sideways swipe.
|
||||
#[test]
|
||||
fn a_horizontal_drag_does_not_scroll() {
|
||||
let (mut f, id) = area();
|
||||
let t = Instant::now();
|
||||
drag(
|
||||
&mut f,
|
||||
id,
|
||||
CursorSense::PressStart(CursorButton::Left),
|
||||
Vec2::new(0.0, 0.0),
|
||||
t,
|
||||
);
|
||||
drag(
|
||||
&mut f,
|
||||
id,
|
||||
CursorSense::Pressing(CursorButton::Left),
|
||||
Vec2::new(120.0, 3.0),
|
||||
t + Duration::from_millis(20),
|
||||
);
|
||||
assert!((f.amt() - 400.0).abs() < 0.01, "amt={}", f.amt());
|
||||
}
|
||||
|
||||
/// Panning stops at the ends of the content rather than running off,
|
||||
/// which is `update_amt`'s clamp -- checked through `drag` so the two
|
||||
/// cannot drift apart.
|
||||
#[test]
|
||||
fn a_pan_past_the_end_clamps_instead_of_running_off() {
|
||||
let (mut f, id) = area();
|
||||
let t = Instant::now();
|
||||
drag(
|
||||
&mut f,
|
||||
id,
|
||||
CursorSense::PressStart(CursorButton::Left),
|
||||
Vec2::new(0.0, 0.0),
|
||||
t,
|
||||
);
|
||||
drag(
|
||||
&mut f,
|
||||
id,
|
||||
CursorSense::Pressing(CursorButton::Left),
|
||||
Vec2::new(0.0, 5000.0),
|
||||
t + Duration::from_millis(20),
|
||||
);
|
||||
assert!((f.amt() - 0.0).abs() < 0.01, "amt={}", f.amt());
|
||||
}
|
||||
|
||||
/// Iris, 2026-09-08: "Flinging doesn't work in horizontal scroll
|
||||
/// areas. Flinging should be enabled by default in all scroll areas
|
||||
/// on android to match composes behavior." A release with real
|
||||
/// velocity coasts, decelerating, and settles on its own.
|
||||
#[test]
|
||||
fn a_released_pan_flings_and_settles() {
|
||||
for axis in [Axis::X, Axis::Y] {
|
||||
let (mut f, id) = area_on(axis);
|
||||
let t = Instant::now();
|
||||
let at = |d: f32| Vec2::from_axis(axis, d, 0.0);
|
||||
|
||||
drag(
|
||||
&mut f,
|
||||
id,
|
||||
CursorSense::PressStart(CursorButton::Left),
|
||||
at(0.0),
|
||||
t,
|
||||
);
|
||||
// Four samples 8ms apart, accelerating away from the start --
|
||||
// three is the fewest `VelocityTracker`'s quadratic fit can
|
||||
// use, so this is a gesture that genuinely has a velocity.
|
||||
for (i, d) in [-40.0, -100.0, -180.0, -280.0].into_iter().enumerate() {
|
||||
drag(
|
||||
&mut f,
|
||||
id,
|
||||
CursorSense::Pressing(CursorButton::Left),
|
||||
at(d),
|
||||
t + Duration::from_millis(8 * (i as u64 + 1)),
|
||||
);
|
||||
}
|
||||
let at_release = f.amt();
|
||||
drag(
|
||||
&mut f,
|
||||
id,
|
||||
CursorSense::PressEnd(CursorButton::Left),
|
||||
at(-280.0),
|
||||
t + Duration::from_millis(32),
|
||||
);
|
||||
assert!(
|
||||
f.get().is_scrolling(),
|
||||
"{axis:?}: a released pan with velocity must fling"
|
||||
);
|
||||
|
||||
// Frames at 8ms until it stops, with each step no longer than
|
||||
// the one before it -- a coast that does not decelerate is
|
||||
// the linear-spline bug this crate has had once already.
|
||||
let mut last_step = f32::INFINITY;
|
||||
let mut ticks = 0;
|
||||
let mut now = t + Duration::from_millis(32);
|
||||
while f.fling_frame(now) {
|
||||
let before = f.amt();
|
||||
now += Duration::from_millis(8);
|
||||
f.fling_frame(now);
|
||||
let step = (f.amt() - before).abs();
|
||||
assert!(
|
||||
step <= last_step + 0.01,
|
||||
"{axis:?}: the fling sped up: {last_step} then {step}"
|
||||
);
|
||||
last_step = step;
|
||||
ticks += 1;
|
||||
assert!(ticks < 10_000, "{axis:?}: the fling never settled");
|
||||
}
|
||||
assert!(
|
||||
f.amt() > at_release,
|
||||
"{axis:?}: the fling moved the content the wrong way: {at_release} -> {}",
|
||||
f.amt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The wall: a fling must not spend its remaining distance on content
|
||||
/// that is not there. Released hard toward the start, it settles
|
||||
/// exactly on it.
|
||||
#[test]
|
||||
fn a_fling_stops_at_the_end_of_the_content() {
|
||||
// Both walls. A positive delta is applied as `amt -= delta`, so a
|
||||
// positive velocity runs toward the start of the content and a
|
||||
// negative one toward its end; 1000px of content in a 100px box
|
||||
// leaves `amt` in 0..=900.
|
||||
for (velocity, wall) in [(50_000.0f32, 0.0f32), (-50_000.0, 900.0)] {
|
||||
let (mut f, _id) = area();
|
||||
f.get().fling(velocity);
|
||||
let t = Instant::now();
|
||||
let mut now = t;
|
||||
for _ in 0..1_000 {
|
||||
if !f.fling_frame(now) {
|
||||
break;
|
||||
}
|
||||
now += Duration::from_millis(8);
|
||||
}
|
||||
assert!(
|
||||
!f.get().is_scrolling(),
|
||||
"the fling toward {wall} ran past the content"
|
||||
);
|
||||
assert!(
|
||||
(f.amt() - wall).abs() < 0.01,
|
||||
"it should have settled on {wall}, got amt={}",
|
||||
f.amt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A finger on coasting content stops it there, from the first
|
||||
/// sample, with no `DRAG_SLOP` to wait out -- the catch
|
||||
/// `DragArbiter::press_start` describes, which a scroll area needs
|
||||
/// for the same reason a list does now that it can coast at all.
|
||||
#[test]
|
||||
fn a_press_on_a_coasting_area_catches_it() {
|
||||
let (mut f, id) = area();
|
||||
f.get().fling(-4_000.0);
|
||||
let t = Instant::now();
|
||||
f.fling_frame(t);
|
||||
f.fling_frame(t + Duration::from_millis(8));
|
||||
let caught_at = f.amt();
|
||||
assert!(f.get().is_scrolling(), "the fixture must still be moving");
|
||||
|
||||
let down = t + Duration::from_millis(16);
|
||||
drag(
|
||||
&mut f,
|
||||
id,
|
||||
CursorSense::PressStart(CursorButton::Left),
|
||||
Vec2::new(0.0, 0.0),
|
||||
down,
|
||||
);
|
||||
assert!(!f.get().is_scrolling(), "a touch-down must end the fling");
|
||||
assert!(
|
||||
(f.amt() - caught_at).abs() < 0.01,
|
||||
"the down itself must not move the content, only stop it"
|
||||
);
|
||||
|
||||
// A move well under `DRAG_SLOP` still tracks the finger, because
|
||||
// this press caught something that was moving.
|
||||
drag(
|
||||
&mut f,
|
||||
id,
|
||||
CursorSense::Pressing(CursorButton::Left),
|
||||
Vec2::new(0.0, 2.0),
|
||||
down + Duration::from_millis(8),
|
||||
);
|
||||
assert!(
|
||||
(f.amt() - (caught_at - 2.0)).abs() < 0.01,
|
||||
"a caught press must pan from its first sample: {} -> {}",
|
||||
caught_at,
|
||||
f.amt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
//! The scrolling capability: one `ScrollController` holding everything a
|
||||
//! scroll position is made of, and a `Scrollable` trait for the widgets
|
||||
//! that own one.
|
||||
//!
|
||||
//! **`docs/SCROLL.md` is the overview** -- the one sign convention, what
|
||||
//! `amt` means, and which widgets scroll. Read it first; this file is the
|
||||
//! detail.
|
||||
//!
|
||||
//! Two widgets scroll in iris and they scroll differently: a
|
||||
//! [`ScrollArea`](super::ScrollArea) slides a fixed child about as a lump,
|
||||
//! and a [`LazySpan`](super::LazySpan) lays its own rows out from an
|
||||
//! anchor and cannot be slid at all. What they share is everything that is
|
||||
//! *not* the layout -- the gesture, the fling, the pin, the position and
|
||||
//! the account of how far it can still go -- so that lives here, in a
|
||||
//! plain struct each of them contains, rather than in a protocol between
|
||||
//! them (Iris, 2026-09-08: "what about adding a scroll controller that
|
||||
//! both scroll and lazy span contain").
|
||||
//!
|
||||
//! The contract with the owner is two calls, both in its `draw`:
|
||||
//!
|
||||
//! 1. [`ScrollController::take_delta`] -- what a wheel, a drag or a fling
|
||||
//! asked for since the last layout, already clamped to the travel the
|
||||
//! owner last reported.
|
||||
//! 2. [`ScrollController::set_travel`], plus whichever of
|
||||
//! [`ScrollController::moved_by`] or [`ScrollController::set_amt`] fits
|
||||
//! how that owner knows where it ended up -- movement for a layout with
|
||||
//! no fixed origin, an absolute position for one that has.
|
||||
//!
|
||||
//! Everything between the two is the owner's own layout, and everything
|
||||
//! outside them is the same for both.
|
||||
|
||||
use crate::prelude::*;
|
||||
use crate::sense::{DragGesture, Flinger, GestureOutcome, PointerRequests, PressState};
|
||||
use std::time::Instant;
|
||||
|
||||
/// Which end of its content a scroll area clings to as that content
|
||||
/// grows, said either way round -- an enum rather than the `at_end: bool`
|
||||
/// this used to be, because the flag sat at the end of two constructors
|
||||
/// and `scrollable(axis, true)` says nothing at the call site about which
|
||||
/// end `true` is.
|
||||
///
|
||||
/// **Two pairs, because there are two questions and they are not the same
|
||||
/// one** (Iris, 2026-09-08: "that way you can select the pin based on the
|
||||
/// axis's sign rather than the direction, so for example you can assure
|
||||
/// it's always pinned to the bottom"):
|
||||
///
|
||||
/// - [`Pin::Start`] / [`Pin::End`] are **content-relative**: the first row
|
||||
/// or the newest one, wherever the layout happens to put it. A
|
||||
/// transcript wants `End` -- the newest message -- and does not care
|
||||
/// which edge of the screen that is.
|
||||
/// - [`Pin::Neg`] / [`Pin::Pos`] are **axis-absolute**: the top or left
|
||||
/// edge, and the bottom or right one, whichever end of the content sits
|
||||
/// there. What to reach for when the *screen* position is the
|
||||
/// requirement.
|
||||
///
|
||||
/// The two coincide for content laid out along the positive axis, which is
|
||||
/// everything except a reversed `LazySpan` (`Dir::UP`, `Dir::LEFT`) --
|
||||
/// where they are exact opposites, which is the whole reason both exist.
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Debug)]
|
||||
pub enum Pin {
|
||||
/// The start of the content: item 0, wherever it is drawn.
|
||||
Start,
|
||||
/// The end of the content: the newest item, wherever it is drawn.
|
||||
End,
|
||||
/// The top or left edge of the box, whichever end of the content is
|
||||
/// there.
|
||||
Neg,
|
||||
/// The bottom or right edge of the box, whichever end of the content
|
||||
/// is there.
|
||||
Pos,
|
||||
}
|
||||
|
||||
impl Pin {
|
||||
/// Resolve to the one question a scrollable actually acts on: does
|
||||
/// content appended to the end bring the view with it? `dir` is the
|
||||
/// way this owner's content runs, which is the only thing that tells
|
||||
/// the axis-absolute pair from the content-relative one.
|
||||
fn pinned_to_end(self, dir: Dir) -> bool {
|
||||
match self {
|
||||
Pin::Start => false,
|
||||
Pin::End => true,
|
||||
Pin::Neg => dir.sign == Sign::Neg,
|
||||
Pin::Pos => dir.sign == Sign::Pos,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a scrollable can still travel from where it is, as of its last
|
||||
/// layout, in the same screen-space units a delta is in.
|
||||
///
|
||||
/// `f32::INFINITY` where the end is not in sight: a lazy layout genuinely
|
||||
/// does not know how much content lies past the rows it has walked, and
|
||||
/// saying "infinity" is the honest answer that `clamp` also happens to
|
||||
/// take with no branch. The wall is then found by the walk, which is why
|
||||
/// the owner reports what it *did* as well as what it can do.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Travel {
|
||||
/// The bound on a **positive** delta -- scrolling up or left, back
|
||||
/// toward the start of the content.
|
||||
pub back: f32,
|
||||
/// The bound on a **negative** delta -- scrolling down or right,
|
||||
/// onward toward the end of the content. Positive itself: it is a
|
||||
/// distance, and the sign it bounds is the caller's.
|
||||
pub fwd: f32,
|
||||
}
|
||||
|
||||
impl Travel {
|
||||
/// Nothing known yet, so nothing is bounded -- what a scrollable
|
||||
/// starts with and what it reports for an axis whose content it has
|
||||
/// not measured.
|
||||
pub const UNBOUNDED: Self = Self {
|
||||
back: f32::INFINITY,
|
||||
fwd: f32::INFINITY,
|
||||
};
|
||||
|
||||
/// The bound on a delta of this sign, as a positive distance.
|
||||
fn toward(&self, delta: f32) -> f32 {
|
||||
if delta >= 0.0 { self.back } else { self.fwd }
|
||||
}
|
||||
}
|
||||
|
||||
/// The state a scroll position is made of, owned by the widget that
|
||||
/// scrolls: where it is, what it was asked to do next, how far it can go,
|
||||
/// which end it clings to, and the gesture and fling that drive it.
|
||||
///
|
||||
/// See the module doc for the two-call contract with its owner, and
|
||||
/// [`Scrollable`] for the trait that reaches one.
|
||||
pub struct ScrollController {
|
||||
/// Which way this area's content runs: the axis it pans along, and the
|
||||
/// sign the content grows in. A plain `ScrollArea` always grows the
|
||||
/// positive way; a `LazySpan` passes its own `dir`, which is what
|
||||
/// tells [`Pin::Pos`]/[`Pin::Neg`] from [`Pin::Start`]/[`Pin::End`].
|
||||
dir: Dir,
|
||||
/// Where this area has got to, counting **forward through the
|
||||
/// content**: 0 at the start, growing as the reader moves on. The
|
||||
/// opposite sign to a delta, which counts the way the finger moves.
|
||||
///
|
||||
/// For a `ScrollArea` it is a position, clamped into the content's
|
||||
/// real length. For a `LazySpan` it is **movement, not position** --
|
||||
/// paging rows in above moves the origin and the span cannot say by
|
||||
/// how much, never having measured them -- so the direction is
|
||||
/// comparable between the two and the absolute value is not.
|
||||
amt: f32,
|
||||
/// Asked for but not yet laid out: how far a wheel, a drag or a fling
|
||||
/// has moved this area since the last draw. Taken and cleared by
|
||||
/// [`Self::take_delta`], which is the only place it is spent, because
|
||||
/// the owner's `draw` is the only place the walls are known.
|
||||
pending: f32,
|
||||
/// What the owner's last layout said was left, and what `take_delta`
|
||||
/// clamps against.
|
||||
travel: Travel,
|
||||
/// Whether this area is currently flush against the end of its
|
||||
/// content, so that content appended to it should bring the view
|
||||
/// along. Set from [`Pin`] at construction and recomputed by the owner
|
||||
/// at the end of every layout -- it is live state, not a preference: a
|
||||
/// reader who scrolls away from the end stops being pinned to it, and
|
||||
/// scrolling back re-pins.
|
||||
pinned_to_end: bool,
|
||||
/// Touch panning. Arbitration, `DRAG_SLOP` and pointer capture all
|
||||
/// live in `sense.rs`; only what a committed pan *means* is decided
|
||||
/// here. See [`Self::drag`].
|
||||
gesture: DragGesture,
|
||||
/// The momentum a release leaves behind. Every scroll area flings, on
|
||||
/// either axis and with nothing to opt into -- Compose's `scrollable`
|
||||
/// attaches `ScrollableDefaults.flingBehavior()` on every axis it is
|
||||
/// given, and Iris asked for the same (2026-09-08: "flinging should be
|
||||
/// enabled by default in all scroll areas on android to match composes
|
||||
/// behavior").
|
||||
fling: Flinger,
|
||||
/// Physical pixels per dp, copied from the painter on every draw --
|
||||
/// what a fling's deceleration is computed against. 1.0 until the
|
||||
/// owner has drawn once, which is also the only state in which nothing
|
||||
/// can be flung, since there is no content measured yet.
|
||||
density: f32,
|
||||
}
|
||||
|
||||
impl ScrollController {
|
||||
pub fn new(dir: Dir, pin: Pin) -> Self {
|
||||
Self {
|
||||
dir,
|
||||
amt: 0.0,
|
||||
pending: 0.0,
|
||||
travel: Travel::UNBOUNDED,
|
||||
pinned_to_end: pin.pinned_to_end(dir),
|
||||
gesture: DragGesture::on(dir.axis),
|
||||
fling: Flinger::new(),
|
||||
density: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Which way this area pans.
|
||||
pub fn axis(&self) -> Axis {
|
||||
self.dir.axis
|
||||
}
|
||||
|
||||
/// Which way this area's content runs -- the axis it pans along and
|
||||
/// the sign it grows in. What resolves a [`Pin`].
|
||||
pub fn dir(&self) -> Dir {
|
||||
self.dir
|
||||
}
|
||||
|
||||
/// How far the content has been pulled past the container's leading
|
||||
/// edge -- see the field for what that means for each kind of owner.
|
||||
pub fn amt(&self) -> f32 {
|
||||
self.amt
|
||||
}
|
||||
|
||||
/// Pan by `amt`, in the finger's direction: **positive scrolls up or
|
||||
/// left**, moving the content the positive way along the axis. One
|
||||
/// convention, everywhere, and a screen direction rather than a
|
||||
/// logical one so that it means the same thing to a widget laid out
|
||||
/// backwards (Iris, 2026-09-08).
|
||||
///
|
||||
/// Banked rather than applied: where this area can actually go is a
|
||||
/// question only its owner's layout can answer, and the owner's `draw`
|
||||
/// is where that answer exists.
|
||||
pub fn scroll(&mut self, amt: f32) {
|
||||
self.pending += amt;
|
||||
}
|
||||
|
||||
/// What has been asked for since the last layout, clamped to the
|
||||
/// travel that layout reported. Called once at the top of the owner's
|
||||
/// `draw`.
|
||||
///
|
||||
/// **Clipping it stops a fling**, because a fling that keeps spending
|
||||
/// its distance on content that is not there is what left a hard flick
|
||||
/// parked a whole screen past the first row of the bench fixture
|
||||
/// (docs/IRIS_TODO.md, 2026-09-07). This catches the wall the owner
|
||||
/// could already see; [`Self::set_travel`] catches the one it finds by
|
||||
/// walking.
|
||||
pub fn take_delta(&mut self) -> f32 {
|
||||
let asked = std::mem::take(&mut self.pending);
|
||||
let limit = self.travel.toward(asked);
|
||||
let taken = asked.clamp(-limit, limit);
|
||||
if taken != asked {
|
||||
self.fling.stop();
|
||||
}
|
||||
taken
|
||||
}
|
||||
|
||||
/// Record content this area really moved, and by how much, in a
|
||||
/// delta's own sign. For an owner that cannot state an absolute
|
||||
/// position -- a lazy layout, whose origin moves as rows are paged in
|
||||
/// above it.
|
||||
pub fn moved_by(&mut self, delta: f32) {
|
||||
self.amt -= delta;
|
||||
}
|
||||
|
||||
/// Set where this area is outright, for an owner that knows: a
|
||||
/// `ScrollArea` has measured its content and clamps against its real
|
||||
/// length, and a jump to an end is a position rather than travel.
|
||||
pub fn set_amt(&mut self, amt: f32) {
|
||||
self.amt = amt;
|
||||
}
|
||||
|
||||
/// Publish how far this area can still go, from the layout that just
|
||||
/// ran. Stops a fling with nothing left in the direction it is
|
||||
/// travelling -- the wall a lazy layout only finds by walking to it,
|
||||
/// reported in the same frame that found it.
|
||||
pub fn set_travel(&mut self, travel: Travel) {
|
||||
self.travel = travel;
|
||||
if let Some(v) = self.fling.velocity()
|
||||
&& travel.toward(v) <= 0.0
|
||||
{
|
||||
self.fling.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// What the last layout said was left. Read by an owner that has to
|
||||
/// reconcile its own walls with what it was allowed to take.
|
||||
pub fn travel(&self) -> Travel {
|
||||
self.travel
|
||||
}
|
||||
|
||||
/// Whether this area is flush against the end of its content, so that
|
||||
/// an appended row should bring the view with it. The owner recomputes
|
||||
/// this at the end of every layout; a caller may set it to re-pin (a
|
||||
/// "jump to latest" button) or to let go.
|
||||
pub fn pinned_to_end(&self) -> bool {
|
||||
self.pinned_to_end
|
||||
}
|
||||
|
||||
pub fn set_pinned_to_end(&mut self, pinned: bool) {
|
||||
self.pinned_to_end = pinned;
|
||||
}
|
||||
|
||||
/// Physical pixels per dp, which a fling's deceleration is computed
|
||||
/// against. Learned from the frame rather than passed in: it is a
|
||||
/// physical quantity, and the owner's `draw` is where it meets the
|
||||
/// only thing that knows it.
|
||||
pub fn set_density(&mut self, density: f32) {
|
||||
self.density = density;
|
||||
}
|
||||
|
||||
/// Start a fling at `velocity`, in [`Self::scroll`]'s direction
|
||||
/// convention. Answers whether one actually started, which is the
|
||||
/// caller's cue to register the widget for frames (`UiData::animate`).
|
||||
/// Cancels any fling already in progress.
|
||||
///
|
||||
/// **Sets the fling; it does not drive it.** A fling moves only while
|
||||
/// something calls [`Self::tick`] once per frame, and what does that
|
||||
/// in a running app is `UiData::tick_animations`, over the ids
|
||||
/// `UiData::animate` was given. Split that way because the two halves
|
||||
/// have different owners: the velocity is this area's business and
|
||||
/// whether anything animates at all is the frame loop's. Missing the
|
||||
/// second call is what a finger fling did on Iris's phone for two
|
||||
/// builds -- the velocity was right and nothing ever advanced it,
|
||||
/// which looks exactly like a list that stops dead under the finger.
|
||||
///
|
||||
/// The density handed on is this area's own, taken from the painter,
|
||||
/// not `1.0`: it does **not** cancel out of the spline, and a
|
||||
/// hardcoded 1.0 against a 2.75-density screen made a flick that
|
||||
/// should coast for about a second run for 45.
|
||||
pub fn fling(&mut self, velocity: f32) -> bool {
|
||||
self.fling.start(velocity, self.density)
|
||||
}
|
||||
|
||||
/// Cancel any fling in progress with no further movement -- the next
|
||||
/// touch-down's job, since `AndroidFlingSpline`'s curve has no idea a
|
||||
/// finger came back down and Android's own `Scroller` relies on the
|
||||
/// view calling `abortAnimation` for the same reason.
|
||||
pub fn cancel_fling(&mut self) {
|
||||
self.fling.stop();
|
||||
}
|
||||
|
||||
/// Whether a fling is coasting here right now. What a caller polls to
|
||||
/// know whether this area is moving on its own (a test, and
|
||||
/// [`PressState::scrolling`]'s own condition).
|
||||
pub fn is_scrolling(&self) -> bool {
|
||||
self.fling.is_flinging()
|
||||
}
|
||||
|
||||
/// The velocity a fling in progress is coasting at, `None` when
|
||||
/// nothing is flinging -- what a release's decision looks like from
|
||||
/// the outside, so a test can read what the gesture measured rather
|
||||
/// than re-timing the gesture itself.
|
||||
pub fn fling_velocity(&self) -> Option<f32> {
|
||||
self.fling.velocity()
|
||||
}
|
||||
|
||||
/// Advance a fling by one frame, banking the distance it covered.
|
||||
/// Answers whether it is still going, which is what
|
||||
/// `UiData::tick_animations` reads to decide whether to keep the
|
||||
/// widget registered -- so an owner's `Widget::tick` is this one line.
|
||||
///
|
||||
/// Stopping at a wall is [`Self::take_delta`]'s and
|
||||
/// [`Self::set_travel`]'s, not this method's: both know where the
|
||||
/// content ends and this one does not.
|
||||
pub fn tick(&mut self, now: Instant) -> bool {
|
||||
let delta = self.fling.tick(now);
|
||||
self.scroll(delta);
|
||||
self.fling.is_flinging()
|
||||
}
|
||||
|
||||
/// Feed one frame of a touch gesture over this area through.
|
||||
/// Registered by `WidgetLike::scrollable`; a caller with an arbiter of
|
||||
/// its own drives `DragGesture` itself and hands the committed pans
|
||||
/// here instead (`transcript_ui::Selection`).
|
||||
///
|
||||
/// `id` is the owning widget's id, which `DragGesture` takes pointer
|
||||
/// capture on once the gesture commits -- so the rest of the drag
|
||||
/// reaches here even after the finger has left the area, and, just as
|
||||
/// importantly, stops reaching whatever is *inside* it. That is what
|
||||
/// resolves a vertical drag over a focused text field: the field sees
|
||||
/// the first few frames, `iris::attr`'s `on_press` gives up its
|
||||
/// pending selection the moment they pass `DRAG_SLOP` vertically, and
|
||||
/// this takes the gesture over. Android's own `EditText` behaves the
|
||||
/// same way -- a vertical drag scrolls, and only a long press selects.
|
||||
///
|
||||
/// Answers whether this frame *started a fling*, which is the caller's
|
||||
/// cue to register the widget for frames (`UiData::animate`) -- see
|
||||
/// [`Self::fling`].
|
||||
pub fn drag(
|
||||
&mut self,
|
||||
pointer: &PointerRequests,
|
||||
id: WidgetId,
|
||||
sense: CursorSense,
|
||||
pos_window: Vec2,
|
||||
now: Instant,
|
||||
) -> bool {
|
||||
// A scroll area has no selection of its own to extend, so a drag
|
||||
// across the axis stays `Undecided` and one along it past the slop
|
||||
// pans, which is the whole contract here.
|
||||
//
|
||||
// `scrolling` is the other half: a finger put down on content that
|
||||
// is still coasting means "stop it here", and commits to a pan on
|
||||
// that very sample with no slop to wait out
|
||||
// (`DragArbiter::press_start`). The fling is cancelled in the same
|
||||
// breath, since the curve has no idea a finger came back down.
|
||||
let mut press = PressState::default();
|
||||
if self.gesture.starts_press(sense) {
|
||||
press.scrolling = self.fling.is_flinging();
|
||||
self.fling.stop();
|
||||
}
|
||||
match self
|
||||
.gesture
|
||||
.handle(pointer, id, sense, pos_window, now, press)
|
||||
{
|
||||
// The content follows the finger, and the same `dy` an
|
||||
// arbiter of the caller's own (`Selection::drag`) hands
|
||||
// straight to `scroll`.
|
||||
GestureOutcome::Pan(dy) => self.scroll(dy),
|
||||
// Same sign as `Pan`, since `tick` applies it through the same
|
||||
// `scroll`.
|
||||
GestureOutcome::Released(Some(v)) => return self.fling(v),
|
||||
GestureOutcome::Undecided
|
||||
| GestureOutcome::Tapped
|
||||
| GestureOutcome::SelectStart
|
||||
| GestureOutcome::SelectExtend
|
||||
| GestureOutcome::Cancelled
|
||||
| GestureOutcome::Released(None) => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget that scrolls its own content. Implementors hand back the
|
||||
/// [`ScrollController`] they own and get everything a caller does with a
|
||||
/// scroll position for free.
|
||||
///
|
||||
/// The two implementors are [`ScrollArea`](super::ScrollArea) and
|
||||
/// [`LazySpan`](super::LazySpan). What distinguishes them is only *how*
|
||||
/// they spend a delta, which is their `draw`'s business -- so a caller
|
||||
/// that pans, flings, reads `amt` or re-pins works through this trait and
|
||||
/// never has to know which it is holding.
|
||||
pub trait Scrollable {
|
||||
fn controller(&self) -> &ScrollController;
|
||||
fn controller_mut(&mut self) -> &mut ScrollController;
|
||||
|
||||
/// Pan by `amt` -- positive scrolls up or left. See
|
||||
/// [`ScrollController::scroll`].
|
||||
fn scroll(&mut self, amt: f32) {
|
||||
self.controller_mut().scroll(amt);
|
||||
}
|
||||
|
||||
/// See [`ScrollController::fling`], including why starting one is not
|
||||
/// the same as driving it.
|
||||
fn fling(&mut self, velocity: f32) -> bool {
|
||||
self.controller_mut().fling(velocity)
|
||||
}
|
||||
|
||||
fn cancel_fling(&mut self) {
|
||||
self.controller_mut().cancel_fling();
|
||||
}
|
||||
|
||||
/// See [`ScrollController::drag`].
|
||||
fn drag(
|
||||
&mut self,
|
||||
pointer: &PointerRequests,
|
||||
id: WidgetId,
|
||||
sense: CursorSense,
|
||||
pos_window: Vec2,
|
||||
now: Instant,
|
||||
) -> bool {
|
||||
self.controller_mut()
|
||||
.drag(pointer, id, sense, pos_window, now)
|
||||
}
|
||||
|
||||
/// See [`ScrollController::amt`] for what this counts, which differs
|
||||
/// between the two implementors in origin though not in direction.
|
||||
fn amt(&self) -> f32 {
|
||||
self.controller().amt()
|
||||
}
|
||||
|
||||
fn axis(&self) -> Axis {
|
||||
self.controller().axis()
|
||||
}
|
||||
|
||||
fn is_scrolling(&self) -> bool {
|
||||
self.controller().is_scrolling()
|
||||
}
|
||||
|
||||
fn fling_velocity(&self) -> Option<f32> {
|
||||
self.controller().fling_velocity()
|
||||
}
|
||||
|
||||
fn pinned_to_end(&self) -> bool {
|
||||
self.controller().pinned_to_end()
|
||||
}
|
||||
|
||||
fn set_pinned_to_end(&mut self, pinned: bool) {
|
||||
self.controller_mut().set_pinned_to_end(pinned);
|
||||
}
|
||||
|
||||
/// Advance a fling by one frame -- an implementor's `Widget::tick` is
|
||||
/// this, and nothing else animates in a scroll area.
|
||||
fn tick_fling(&mut self, now: Instant) -> bool {
|
||||
self.controller_mut().tick(now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the two inputs of a scroll -- the wheel and a finger drag --
|
||||
/// on a widget that owns a [`ScrollController`], and hand back the id.
|
||||
///
|
||||
/// The one place either is wired, shared by `WidgetLike::scrollable` and
|
||||
/// `LazySpan::scrollable`: what differs between those two is only whether
|
||||
/// there is a `ScrollArea` in the way, and a drag registered twice is a
|
||||
/// gesture arbitrated twice.
|
||||
pub fn scroll_senses<Rsc, Tag, W, WL>(w: WL, axis: Axis) -> impl WidgetIdFn<Rsc, W>
|
||||
where
|
||||
Rsc: HasEvents,
|
||||
W: Widget + Scrollable,
|
||||
WL: WidgetLike<Rsc, Tag, Widget = W>,
|
||||
{
|
||||
w.on(CursorSense::Scroll, move |ctx, rsc| {
|
||||
let delta = ctx.data.scroll_delta.axis(axis) * 50.0;
|
||||
ctx.widget(rsc).scroll(delta);
|
||||
})
|
||||
.on(CursorSense::drag_senses(), |ctx, rsc: &mut Rsc| {
|
||||
let id = ctx.widget.id();
|
||||
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
|
||||
let flung = ctx
|
||||
.widget(rsc)
|
||||
.drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time);
|
||||
// The half that actually makes it move -- a fling is set by the
|
||||
// widget and driven by the frame loop, and only this side can
|
||||
// reach the loop. Only when one actually started: registering a
|
||||
// widget that is not animating asks the next frame to find that
|
||||
// out.
|
||||
if flung {
|
||||
rsc.ui_mut().animate(id);
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -6,29 +6,32 @@ pub struct Sized {
|
||||
pub y: Option<Len>,
|
||||
}
|
||||
|
||||
impl Sized {
|
||||
fn apply_to_outer(&self, ctx: &mut SizeCtx) {
|
||||
impl Widget for Sized {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
// The child is drawn within a region that actually carves out the
|
||||
// fixed axes, not whatever region this widget itself happened to
|
||||
// be offered -- needed so the painted geometry matches the
|
||||
// declared size returned below regardless of how much room a
|
||||
// parent offers. `Aligned`'s single-draw pattern (LAYOUT.md
|
||||
// section 6) draws its child once at its own *full* region to
|
||||
// learn its size, then moves it into place with a pure
|
||||
// translation; that translation is only valid if what got painted
|
||||
// is already the reported size, anchored the same way both times.
|
||||
let density = painter.density();
|
||||
let mut region = UiRegion::FULL;
|
||||
if let Some(x) = self.x {
|
||||
ctx.outer.x.select_len(x.apply_rest());
|
||||
region.x = x.apply_rest(density).align(AxisAlign::Neg);
|
||||
}
|
||||
if let Some(y) = self.y {
|
||||
ctx.outer.y.select_len(y.apply_rest());
|
||||
region.y = y.apply_rest(density).align(AxisAlign::Neg);
|
||||
}
|
||||
let used = painter.widget_within(&self.inner, region);
|
||||
// `fold_dp` on the way out: a declared size is a `Len` the caller
|
||||
// wrote (`.width(dp(48))`), and a *reported* one may not carry an
|
||||
// unresolved `dp` -- see `Len::fold_dp`.
|
||||
Size {
|
||||
x: self.x.map(|x| x.fold_dp(density)).unwrap_or(used.x),
|
||||
y: self.y.map(|y| y.fold_dp(density)).unwrap_or(used.y),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for Sized {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
painter.widget(&self.inner);
|
||||
}
|
||||
|
||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
self.apply_to_outer(ctx);
|
||||
self.x.unwrap_or_else(|| ctx.width(&self.inner))
|
||||
}
|
||||
|
||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
self.apply_to_outer(ctx);
|
||||
self.y.unwrap_or_else(|| ctx.height(&self.inner))
|
||||
}
|
||||
}
|
||||
+63
-109
@@ -4,17 +4,48 @@ use std::marker::PhantomData;
|
||||
pub struct Span {
|
||||
pub children: Vec<StrongWidget>,
|
||||
pub dir: Dir,
|
||||
pub gap: f32,
|
||||
/// A `Len` (not a bare `f32`) so `dp(4)` resolves against the display's
|
||||
/// density the same way any other size in the tree does -- see
|
||||
/// `Len::dp`'s field doc. Only the `abs` component (folded from `dp` at
|
||||
/// draw time, `Widget::draw` below) is meaningful here; `rel`/`rest`
|
||||
/// were never supported for a gap and still are not.
|
||||
pub gap: Len,
|
||||
}
|
||||
|
||||
impl Widget for Span {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
let total = self.len_sum(&mut painter.size_ctx());
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
let axis = self.dir.axis;
|
||||
let gap = self.gap.apply_rest(painter.density()).abs;
|
||||
|
||||
// Phase 1: draw each child once, at the ambient (unmodified, full)
|
||||
// region a size-only query used to see before this migration, to
|
||||
// learn its length along the layout axis. This paints real
|
||||
// primitives at a provisional slot; phase 2 below places each
|
||||
// child for real via the normal `widget_within` dispatch, which
|
||||
// only actually redraws it when that slot's *size* differs from
|
||||
// this provisional one (most children: a resize, since the
|
||||
// provisional slot is the whole span, not this child's share).
|
||||
let lens: Vec<Len> = self
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| painter.widget(child).axis(axis))
|
||||
.collect();
|
||||
|
||||
let gap_total = gap * self.children.len().saturating_sub(1) as f32;
|
||||
let total = lens.iter().fold(Len::abs(gap_total), |s, &l| s + l);
|
||||
|
||||
// Phase 2: place each child for real, using the lengths just
|
||||
// learned -- the same arithmetic this loop always used. The cross-
|
||||
// axis length of *this* draw (used for `Span`'s own reported size
|
||||
// below) falls out of each child's real, resolved-width `used`
|
||||
// here for free -- this is what replaces `desired_ortho`'s former
|
||||
// duplicate simulation of this same loop (see LAYOUT.md section 4).
|
||||
let mut start = UiScalar::rel_min();
|
||||
for child in &self.children {
|
||||
let mut ortho_len = Len::ZERO;
|
||||
let mut ortho_mixed = false;
|
||||
for (child, &len) in self.children.iter().zip(&lens) {
|
||||
let mut span = UiSpan::FULL;
|
||||
span.start = start;
|
||||
let len = painter.len_axis(child, self.dir.axis);
|
||||
if len.rest > 0.0 {
|
||||
let offset = UiScalar::new(total.rel, total.abs);
|
||||
let rel_end = UiScalar::rel(len.rest / total.rest);
|
||||
@@ -24,27 +55,31 @@ impl Widget for Span {
|
||||
start.abs += len.abs;
|
||||
start.rel += len.rel;
|
||||
span.end = start;
|
||||
let mut child_region = UiRegion::from_axis(self.dir.axis, span, UiSpan::FULL);
|
||||
let mut child_region = UiRegion::from_axis(axis, span, UiSpan::FULL);
|
||||
if self.dir.sign == Sign::Neg {
|
||||
child_region.flip(self.dir.axis);
|
||||
child_region.flip(axis);
|
||||
}
|
||||
painter.widget_within(child, child_region);
|
||||
start.abs += self.gap;
|
||||
}
|
||||
}
|
||||
let used = painter.widget_within(child, child_region);
|
||||
start.abs += gap;
|
||||
|
||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
match self.dir.axis {
|
||||
Axis::X => self.desired_len(ctx),
|
||||
Axis::Y => self.desired_ortho(ctx),
|
||||
let ortho = used.axis(!axis);
|
||||
if ortho.rel > 0.0 || ortho.rest > 0.0 {
|
||||
ortho_mixed = true;
|
||||
} else {
|
||||
ortho_len.abs = ortho_len.abs.max(ortho.abs);
|
||||
}
|
||||
}
|
||||
if ortho_mixed {
|
||||
ortho_len = Len::default();
|
||||
}
|
||||
}
|
||||
|
||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
match self.dir.axis {
|
||||
Axis::X => self.desired_ortho(ctx),
|
||||
Axis::Y => self.desired_len(ctx),
|
||||
}
|
||||
let along = if total.rest == 0.0 && total.rel == 0.0 {
|
||||
total
|
||||
} else {
|
||||
Len::default()
|
||||
};
|
||||
|
||||
Size::from_axis(axis, along, ortho_len)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,12 +88,12 @@ impl Span {
|
||||
Self {
|
||||
children: Vec::new(),
|
||||
dir,
|
||||
gap: 0.0,
|
||||
gap: Len::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gap(mut self, gap: impl UiNum) -> Self {
|
||||
self.gap = gap.to_f32();
|
||||
pub fn gap(mut self, gap: impl Into<Len>) -> Self {
|
||||
self.gap = gap.into();
|
||||
self
|
||||
}
|
||||
|
||||
@@ -69,93 +104,12 @@ impl Span {
|
||||
pub fn pop(&mut self) -> Option<StrongWidget> {
|
||||
self.children.pop()
|
||||
}
|
||||
|
||||
fn len_sum(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
let gap = self.gap * self.children.len().saturating_sub(1) as f32;
|
||||
self.children.iter().fold(Len::abs(gap), |mut s, id| {
|
||||
// it's tempting to subtract the abs & rel from the ctx outer,
|
||||
// but that would create inconsistent sizing if you put
|
||||
// a rest first vs last & only speed up in one direction.
|
||||
// I think this is only solvable by restricting how you can
|
||||
// compute size, bc currently you need child to define parent's
|
||||
// sectioning and you need parent's sectioning to define child.
|
||||
// Fortunately, that doesn't matter in most cases
|
||||
let len = ctx.len_axis(id, self.dir.axis);
|
||||
s += len;
|
||||
s
|
||||
})
|
||||
}
|
||||
|
||||
fn desired_len(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
let len = self.len_sum(ctx);
|
||||
if len.rest == 0.0 && len.rel == 0.0 {
|
||||
len
|
||||
} else {
|
||||
Len::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn desired_ortho(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
// this is a weird hack to get text wrapping to work properly when in a downward span
|
||||
// the correct solution here is to add a function to widget that lets them
|
||||
// request that ctx.outer has an axis "resolved" before checking the other,
|
||||
// and panicking or warning if two request opposite axis (unsolvable in that case)
|
||||
let outer = ctx.outer.axis(self.dir.axis);
|
||||
if self.dir.axis == Axis::X {
|
||||
// so....... this literally copies draw so that the lengths are correctly set in the
|
||||
// context, which makes this slow and not cool
|
||||
let total = self.len_sum(ctx);
|
||||
let mut start = UiScalar::rel_min();
|
||||
let mut ortho_len = Len::ZERO;
|
||||
for child in &self.children {
|
||||
let mut span = UiSpan::FULL;
|
||||
span.start = start;
|
||||
let len = ctx.len_axis(child, self.dir.axis);
|
||||
if len.rest > 0.0 {
|
||||
let offset = UiScalar::new(total.rel, total.abs);
|
||||
let rel_end = UiScalar::rel(len.rest / total.rest);
|
||||
let end = (UiScalar::rel_max() + start) - offset;
|
||||
start = rel_end.within(&start.to(end));
|
||||
}
|
||||
start.abs += len.abs;
|
||||
start.rel += len.rel;
|
||||
span.end = start;
|
||||
|
||||
let scalar = span.len();
|
||||
*ctx.outer.axis_mut(self.dir.axis) = outer.select_len(scalar);
|
||||
let ortho = ctx.len_axis(child, !self.dir.axis);
|
||||
// TODO: rel shouldn't do this, but no easy way before actually calculating pixels
|
||||
if ortho.rel > 0.0 || ortho.rest > 0.0 {
|
||||
ortho_len.rest = 1.0;
|
||||
ortho_len.abs = 0.0;
|
||||
break;
|
||||
}
|
||||
ortho_len.abs = ortho_len.abs.max(ortho.abs);
|
||||
start.abs += self.gap;
|
||||
}
|
||||
ortho_len
|
||||
} else {
|
||||
let mut ortho_len = Len::ZERO;
|
||||
let ortho = !self.dir.axis;
|
||||
for child in &self.children {
|
||||
let len = ctx.len_axis(child, ortho);
|
||||
// TODO: rel shouldn't do this, but no easy way before actually calculating pixels
|
||||
if len.rel > 0.0 || len.rest > 0.0 {
|
||||
ortho_len.rest = 1.0;
|
||||
ortho_len.abs = 0.0;
|
||||
break;
|
||||
}
|
||||
ortho_len.abs = ortho_len.abs.max(len.abs);
|
||||
}
|
||||
ortho_len
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SpanBuilder<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag> {
|
||||
pub children: Wa,
|
||||
pub dir: Dir,
|
||||
pub gap: f32,
|
||||
pub gap: Len,
|
||||
_pd: PhantomData<(State, Tag)>,
|
||||
}
|
||||
|
||||
@@ -181,13 +135,13 @@ impl<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag>
|
||||
Self {
|
||||
children,
|
||||
dir,
|
||||
gap: 0.0,
|
||||
gap: Len::ZERO,
|
||||
_pd: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gap(mut self, gap: impl UiNum) -> Self {
|
||||
self.gap = gap.to_f32();
|
||||
pub fn gap(mut self, gap: impl Into<Len>) -> Self {
|
||||
self.gap = gap.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,29 +8,26 @@ pub struct Stack {
|
||||
}
|
||||
|
||||
impl Widget for Stack {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
let mut iter = self.children.iter();
|
||||
if let Some(child) = iter.next() {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
let mut picked = None;
|
||||
let mut iter = self.children.iter().enumerate();
|
||||
if let Some((i, child)) = iter.next() {
|
||||
painter.child_layer();
|
||||
painter.widget(child);
|
||||
let used = painter.widget(child);
|
||||
if matches!(self.size, StackSize::Child(j) if j == i) {
|
||||
picked = Some(used);
|
||||
}
|
||||
}
|
||||
for child in iter {
|
||||
for (i, child) in iter {
|
||||
painter.next_layer();
|
||||
painter.widget(child);
|
||||
let used = painter.widget(child);
|
||||
if matches!(self.size, StackSize::Child(j) if j == i) {
|
||||
picked = Some(used);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
match self.size {
|
||||
StackSize::Default => Len::default(),
|
||||
StackSize::Child(i) => ctx.width(&self.children[i]),
|
||||
}
|
||||
}
|
||||
|
||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
match self.size {
|
||||
StackSize::Default => Len::default(),
|
||||
StackSize::Child(i) => ctx.height(&self.children[i]),
|
||||
StackSize::Default => Size::default(),
|
||||
StackSize::Child(_) => picked.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-16
@@ -6,26 +6,16 @@ pub struct WidgetPtr {
|
||||
}
|
||||
|
||||
impl Widget for WidgetPtr {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
if let Some(id) = &self.inner {
|
||||
painter.widget(id);
|
||||
painter.widget(id)
|
||||
} else {
|
||||
Size::ZERO
|
||||
}
|
||||
}
|
||||
|
||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
if let Some(id) = &self.inner {
|
||||
ctx.width(id)
|
||||
} else {
|
||||
Len::ZERO
|
||||
}
|
||||
}
|
||||
|
||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
if let Some(id) = &self.inner {
|
||||
ctx.height(id)
|
||||
} else {
|
||||
Len::ZERO
|
||||
}
|
||||
fn is_size_independent(&self) -> bool {
|
||||
self.inner.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+40
-12
@@ -3,7 +3,13 @@ use crate::prelude::*;
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Rect {
|
||||
pub color: UiColor,
|
||||
pub radius: f32,
|
||||
/// A `Len` rather than a raw `f32` so a corner can be written in `dp`
|
||||
/// and come out the same physical size on every display -- resolved
|
||||
/// against `Painter::density` in [`Rect::draw`], the same place every
|
||||
/// other `dp` is resolved. A plain number still works and still means
|
||||
/// physical pixels (`impl<N: UiNum> From<N> for Len`), which is what
|
||||
/// a hairline wants.
|
||||
pub radius: Len,
|
||||
pub thickness: f32,
|
||||
pub inner_radius: f32,
|
||||
}
|
||||
@@ -12,7 +18,7 @@ impl Rect {
|
||||
pub fn new(color: UiColor) -> Self {
|
||||
Self {
|
||||
color,
|
||||
radius: 0.0,
|
||||
radius: Len::ZERO,
|
||||
inner_radius: 0.0,
|
||||
thickness: 0.0,
|
||||
}
|
||||
@@ -21,28 +27,50 @@ impl Rect {
|
||||
self.color = color;
|
||||
self
|
||||
}
|
||||
pub fn radius(mut self, radius: impl UiNum) -> Self {
|
||||
self.radius = radius.to_f32();
|
||||
pub fn radius(mut self, radius: impl Into<Len>) -> Self {
|
||||
self.radius = radius.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for Rect {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
painter.primitive(RectPrimitive {
|
||||
color: self.color,
|
||||
radius: self.radius,
|
||||
// `rel` has no meaning for a corner (a rect that fills its
|
||||
// parent has no length of its own to take a fraction of), so
|
||||
// only the `abs`/`dp` halves are folded.
|
||||
radius: self.radius.fold_dp(painter.density()).abs,
|
||||
thickness: self.thickness,
|
||||
inner_radius: self.inner_radius,
|
||||
});
|
||||
Size::REST // fills whatever it was given -- used == available
|
||||
}
|
||||
|
||||
fn desired_width(&mut self, _: &mut SizeCtx) -> Len {
|
||||
Len::rest(1)
|
||||
}
|
||||
|
||||
fn desired_height(&mut self, _: &mut SizeCtx) -> Len {
|
||||
Len::rest(1)
|
||||
/// **No** -- despite drawing one primitive and nothing else.
|
||||
///
|
||||
/// `is_size_independent` asks whether the widget's *content* is
|
||||
/// unaffected by how big a region it was given, so that
|
||||
/// `draw_inner` may keep the primitives it already has and rewrite
|
||||
/// their regions in place. A `Rect`'s content **is** its region: it
|
||||
/// returns `Size::REST` and fills whatever it was handed, so the fast
|
||||
/// path's `r.outside(&from).within(®ion)` remap has to reproduce
|
||||
/// the whole of `draw` -- and it does not, because a region carries
|
||||
/// `rel` and `abs` components that the round trip cannot recover
|
||||
/// separately.
|
||||
///
|
||||
/// What that looked like: a fenced code block's background
|
||||
/// (`transcript-ui`'s `BlockFrame::Verbatim`, a `Rect` behind a
|
||||
/// `Pad` in a `Stack`) kept the height of the *provisional* full-
|
||||
/// region draw `Span` does in its first phase, so one fence's panel
|
||||
/// covered every block below it -- and every row below that -- while
|
||||
/// the text itself was laid out correctly. Visible in
|
||||
/// `docs/bench/p1a-2026-09-06/`'s history and reproduced by this
|
||||
/// crate's `transcript` example. Answering `false` costs a redraw of
|
||||
/// one primitive when a rect is resized, which is what the fast path
|
||||
/// was saving.
|
||||
fn is_size_independent(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-20
@@ -1,10 +1,10 @@
|
||||
use crate::prelude::*;
|
||||
use cosmic_text::{Attrs, Family, Metrics};
|
||||
use std::marker::{PhantomData, Sized};
|
||||
|
||||
pub struct TextBuilder<State, O = TextOutput, H: WidgetOption<State> = ()> {
|
||||
pub content: String,
|
||||
pub attrs: TextAttrs,
|
||||
pub spans: Vec<SpanStyle>,
|
||||
pub hint: H,
|
||||
pub output: O,
|
||||
state: PhantomData<State>,
|
||||
@@ -20,7 +20,7 @@ impl<State, O, H: WidgetOption<State>> TextBuilder<State, O, H> {
|
||||
self.attrs.color = color;
|
||||
self
|
||||
}
|
||||
pub fn family(mut self, family: Family<'static>) -> Self {
|
||||
pub fn family(mut self, family: Family) -> Self {
|
||||
self.attrs.family = family;
|
||||
self
|
||||
}
|
||||
@@ -40,10 +40,19 @@ impl<State, O, H: WidgetOption<State>> TextBuilder<State, O, H> {
|
||||
self.attrs.wrap = wrap;
|
||||
self
|
||||
}
|
||||
/// Per-range style overrides -- I5's inline rich text (bold, italic,
|
||||
/// inline-code monospace, link colour/underline) within one wrapped
|
||||
/// paragraph. See `SpanStyle`'s doc for why this exists and what it
|
||||
/// replaces.
|
||||
pub fn spans(mut self, spans: Vec<SpanStyle>) -> Self {
|
||||
self.spans = spans;
|
||||
self
|
||||
}
|
||||
pub fn editable(self, mode: EditMode) -> TextBuilder<State, TextEditOutput, H> {
|
||||
TextBuilder {
|
||||
content: self.content,
|
||||
attrs: self.attrs,
|
||||
spans: self.spans,
|
||||
hint: self.hint,
|
||||
output: TextEditOutput { mode },
|
||||
state: PhantomData,
|
||||
@@ -59,6 +68,7 @@ impl<Rsc: UiRsc, O> TextBuilder<Rsc, O> {
|
||||
TextBuilder {
|
||||
content: self.content,
|
||||
attrs: self.attrs,
|
||||
spans: self.spans,
|
||||
hint: move |rsc: &mut Rsc| Some(hint.add_strong(rsc).any()),
|
||||
output: self.output,
|
||||
state: PhantomData,
|
||||
@@ -82,19 +92,14 @@ impl<Rsc: UiRsc> TextBuilderOutput<Rsc> for TextOutput {
|
||||
state: &mut Rsc,
|
||||
builder: TextBuilder<Rsc, Self, H>,
|
||||
) -> Self::Output {
|
||||
let mut buf = TextBuffer::new_empty(Metrics::new(
|
||||
builder.attrs.font_size,
|
||||
builder.attrs.line_height,
|
||||
));
|
||||
let mut buf = TextBuffer::new(&builder.content);
|
||||
buf.set_spans(builder.spans);
|
||||
let hint = builder.hint.get(state);
|
||||
let font_system = &mut state.ui_mut().text.font_system;
|
||||
buf.set_text(font_system, &builder.content, &Attrs::new(), SHAPING, None);
|
||||
let mut text = Text {
|
||||
content: builder.content.into(),
|
||||
view: TextView::new(buf, builder.attrs, hint),
|
||||
};
|
||||
text.content.changed = false;
|
||||
builder.attrs.apply(font_system, &mut text.view.buf, None);
|
||||
text
|
||||
}
|
||||
}
|
||||
@@ -110,19 +115,12 @@ impl<State: UiRsc> TextBuilderOutput<State> for TextEditOutput {
|
||||
state: &mut State,
|
||||
builder: TextBuilder<State, Self, H>,
|
||||
) -> Self::Output {
|
||||
let buf = TextBuffer::new_empty(Metrics::new(
|
||||
builder.attrs.font_size,
|
||||
builder.attrs.line_height,
|
||||
));
|
||||
let mut text = TextEdit::new(
|
||||
let mut buf = TextBuffer::new(&builder.content);
|
||||
buf.set_spans(builder.spans);
|
||||
TextEdit::new(
|
||||
TextView::new(buf, builder.attrs, builder.hint.get(state)),
|
||||
builder.output.mode,
|
||||
);
|
||||
let font_system = &mut state.ui_mut().text.font_system;
|
||||
text.buf
|
||||
.set_text(font_system, &builder.content, &Attrs::new(), SHAPING, None);
|
||||
builder.attrs.apply(font_system, &mut text.buf, None);
|
||||
text
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +138,7 @@ pub fn wtext<State>(content: impl Into<String>) -> TextBuilder<State> {
|
||||
TextBuilder {
|
||||
content: content.into(),
|
||||
attrs: TextAttrs::default(),
|
||||
spans: Vec::new(),
|
||||
hint: (),
|
||||
output: TextOutput,
|
||||
state: PhantomData,
|
||||
|
||||
+655
-397
File diff suppressed because it is too large.
Load diff
+114
-89
@@ -6,11 +6,8 @@ pub use edit::*;
|
||||
use iris_core::util::MutDetect;
|
||||
|
||||
use crate::prelude::*;
|
||||
use cosmic_text::{Attrs, BufferLine, Cursor, Metrics, Shaping};
|
||||
use std::ops::{Deref, DerefMut};
|
||||
|
||||
pub const SHAPING: Shaping = Shaping::Advanced;
|
||||
|
||||
pub struct Text {
|
||||
pub content: MutDetect<String>,
|
||||
view: TextView,
|
||||
@@ -25,6 +22,18 @@ pub struct TextView {
|
||||
pub hint: Option<StrongWidget>,
|
||||
}
|
||||
|
||||
impl TextView {
|
||||
fn is_blank(&self) -> bool {
|
||||
self.buf.is_empty()
|
||||
}
|
||||
|
||||
/// The width the text was last laid out against, so an editor asking for
|
||||
/// the layout gets the same wrapping the last draw used.
|
||||
pub fn wrap_width(&self) -> Option<f32> {
|
||||
self.width
|
||||
}
|
||||
}
|
||||
|
||||
impl TextView {
|
||||
pub fn new(buf: TextBuffer, attrs: TextAttrs, hint: Option<StrongWidget>) -> Self {
|
||||
Self {
|
||||
@@ -45,33 +54,44 @@ impl TextView {
|
||||
.align(self.align)
|
||||
}
|
||||
|
||||
fn tex_region(&self, tex: &RenderedText) -> UiRegion {
|
||||
let region = tex.size.align(self.align);
|
||||
let dims = tex.handle.size();
|
||||
let mut region = region.offset(tex.top_left_offset);
|
||||
region.x.end = region.x.start + UiScalar::abs(dims.x);
|
||||
region.y.end = region.y.start + UiScalar::abs(dims.y);
|
||||
region
|
||||
}
|
||||
|
||||
fn render(&mut self, ctx: &mut SizeCtx) -> RenderedText {
|
||||
fn render(&mut self, painter: &mut Painter) -> RenderedText {
|
||||
let width = if self.attrs.wrap {
|
||||
Some(ctx.px_size().x)
|
||||
Some(painter.px_size().x)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// The atlas generation is part of the cache key, not a separate
|
||||
// invalidation path: a `RenderedText` is only meaningful against the
|
||||
// atlas its glyphs were placed in, and a renderer rebuild clears
|
||||
// that atlas out from under every widget at once
|
||||
// (`GlyphAtlas::clear`). Without this the text drawn before the
|
||||
// rebuild is re-emitted with the old atlas's coordinates and comes
|
||||
// back as fragments of whatever now occupies them.
|
||||
let generation = painter.atlas_generation();
|
||||
if width == self.width
|
||||
&& let Some(tex) = &self.tex
|
||||
&& tex.generation == generation
|
||||
&& !self.attrs.changed
|
||||
&& !self.buf.changed
|
||||
{
|
||||
return tex.clone();
|
||||
}
|
||||
self.width = width;
|
||||
let font_system = &mut ctx.text.font_system;
|
||||
self.attrs.apply(font_system, &mut self.buf, width);
|
||||
self.buf.shape_until_scroll(font_system, false);
|
||||
let tex = ctx.draw_text(&mut self.buf, &self.attrs);
|
||||
let tex = painter.render_text(&mut self.buf, &self.attrs, width);
|
||||
// Gated on `iris::diagnostics::trace_enabled` since 2026-09-07
|
||||
// (docs/RUST.md's review, D1): one line per text *shape* (a cache
|
||||
// miss), unconditional, is many per frame while rows compose --
|
||||
// see `android::view::IrisViewPeer::render`'s own doc for the same
|
||||
// finding on its two per-frame lines.
|
||||
if crate::diagnostics::trace_enabled() {
|
||||
log::debug!(
|
||||
target: "iris::frame",
|
||||
"iris text render: chars={} width={width:?} glyphs={} size={:?}",
|
||||
self.buf.text().chars().count(),
|
||||
tex.glyphs.len(),
|
||||
tex.size,
|
||||
);
|
||||
}
|
||||
self.tex = Some(tex.clone());
|
||||
self.attrs.changed = false;
|
||||
self.buf.changed = false;
|
||||
@@ -80,98 +100,51 @@ impl TextView {
|
||||
pub fn tex(&self) -> Option<&RenderedText> {
|
||||
self.tex.as_ref()
|
||||
}
|
||||
pub fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
if let Some(hint) = &self.hint
|
||||
&& let [line] = &self.buf.lines[..]
|
||||
&& line.text().is_empty()
|
||||
/// Draws within `painter.region()` and reports the size used -- what
|
||||
/// `desired_width`/`desired_height` used to answer separately, folded
|
||||
/// into the one draw (LAYOUT.md section 4): the shaped layout this
|
||||
/// reads is already memoized by width in `render`, so a second call at
|
||||
/// the same width (a redraw with nothing else changed) is a cache hit,
|
||||
/// not a re-shape.
|
||||
pub fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
let tex = self.render(painter);
|
||||
if self.is_blank()
|
||||
&& let Some(hint) = &self.hint
|
||||
{
|
||||
ctx.width(hint)
|
||||
} else {
|
||||
Len::abs(self.render(ctx).size.x)
|
||||
return painter.widget(hint);
|
||||
}
|
||||
}
|
||||
pub fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
if let Some(hint) = &self.hint
|
||||
&& let [line] = &self.buf.lines[..]
|
||||
&& line.text().is_empty()
|
||||
{
|
||||
ctx.height(hint)
|
||||
} else {
|
||||
Len::abs(self.render(ctx).size.y)
|
||||
}
|
||||
}
|
||||
pub fn draw(&mut self, painter: &mut Painter) -> UiRegion {
|
||||
let tex = self.render(&mut painter.size_ctx());
|
||||
let region = self.tex_region(&tex);
|
||||
if let Some(hint) = &self.hint
|
||||
&& let [line] = &self.buf.lines[..]
|
||||
&& line.text().is_empty()
|
||||
{
|
||||
painter.widget(hint);
|
||||
} else {
|
||||
painter.texture_within(&tex.handle, region);
|
||||
}
|
||||
region
|
||||
let region = tex.size.align(self.align);
|
||||
let within = region.within(&painter.region());
|
||||
painter.glyphs(&tex, within);
|
||||
Size::abs(tex.size)
|
||||
}
|
||||
|
||||
pub fn content(&self) -> String {
|
||||
self.buf
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| l.text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
self.buf.text().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Text {
|
||||
pub fn new(content: impl Into<String>) -> Self {
|
||||
let attrs = TextAttrs::default();
|
||||
let buf = TextBuffer::new_empty(Metrics::new(attrs.font_size, attrs.line_height));
|
||||
let content: String = content.into();
|
||||
Self {
|
||||
content: content.into().into(),
|
||||
view: TextView::new(buf, attrs, None),
|
||||
view: TextView::new(TextBuffer::new(&content), TextAttrs::default(), None),
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
fn update_buf(&mut self, ctx: &mut SizeCtx) {
|
||||
fn update_buf(&mut self) {
|
||||
if self.content.changed {
|
||||
self.content.changed = false;
|
||||
self.view.buf.set_text(
|
||||
&mut ctx.text.font_system,
|
||||
&self.content,
|
||||
&Attrs::new().family(self.view.attrs.family),
|
||||
SHAPING,
|
||||
None,
|
||||
);
|
||||
self.view.buf.set_text(self.content.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for Text {
|
||||
fn draw(&mut self, painter: &mut Painter) {
|
||||
self.update_buf(&mut painter.size_ctx());
|
||||
self.view.draw(painter);
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
self.update_buf();
|
||||
self.view.draw(painter)
|
||||
}
|
||||
|
||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
self.update_buf(ctx);
|
||||
self.view.desired_width(ctx)
|
||||
}
|
||||
|
||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
||||
self.update_buf(ctx);
|
||||
self.view.desired_height(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sort_cursors(a: Cursor, b: Cursor) -> (Cursor, Cursor) {
|
||||
let start = a.min(b);
|
||||
let end = a.max(b);
|
||||
(start, end)
|
||||
}
|
||||
|
||||
pub fn edit_line(line: &mut BufferLine, text: String) {
|
||||
line.set_text(text, line.ending(), line.attrs_list().clone());
|
||||
}
|
||||
|
||||
impl Deref for Text {
|
||||
@@ -201,3 +174,55 @@ impl DerefMut for TextView {
|
||||
&mut self.attrs
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::layout_tests::TestRsc;
|
||||
use crate::prelude::*;
|
||||
|
||||
/// A renderer rebuild empties the glyph atlas under every widget at
|
||||
/// once (`iris_core::GlyphAtlas::clear`, called from
|
||||
/// `IrisViewPeer::surface_changed`'s new-renderer branch). Anything
|
||||
/// still holding a `RenderedText` from before then owns UV rectangles
|
||||
/// into a texture that no longer exists -- what Iris photographed on
|
||||
/// 2026-09-06 as every pre-resume glyph coming back as fragments while
|
||||
/// the text drawn after the resume was perfect.
|
||||
///
|
||||
/// The check is the atlas repopulating: `TextView::render`'s cache
|
||||
/// short-circuits before `TextData::place`, so without the generation
|
||||
/// in its key the second frame rasterises nothing and the atlas stays
|
||||
/// empty. (`Painter::glyphs`'s `debug_assert!` fires here too, which is
|
||||
/// the same finding from the submission side.)
|
||||
#[test]
|
||||
fn clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let root = wtext("hello there")
|
||||
.size(18)
|
||||
.color(UiColor::WHITE)
|
||||
.add_strong(&mut rsc)
|
||||
.any();
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((800.0, 600.0));
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let rasterised = rsc.ui.text.atlas.glyph_count();
|
||||
assert!(rasterised > 0, "the first frame rasterised no glyphs");
|
||||
|
||||
// Exactly what the new-renderer branch does, in order: empty the
|
||||
// atlas, then redraw everything (`resize` is what marks the tree
|
||||
// for a full redraw, and a real `surface_changed` always calls it).
|
||||
rsc.ui.text.atlas.clear();
|
||||
assert_eq!(rsc.ui.text.atlas.glyph_count(), 0);
|
||||
render.resize((800.0, 600.0));
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
assert_eq!(
|
||||
rsc.ui.text.atlas.glyph_count(),
|
||||
rasterised,
|
||||
"the second frame re-emitted its cached glyphs instead of \
|
||||
re-rendering them against the fresh atlas"
|
||||
);
|
||||
}
|
||||
}
|
||||
+39
-7
@@ -83,19 +83,51 @@ widget_trait! {
|
||||
}
|
||||
}
|
||||
|
||||
fn scrollable(self) -> impl WidgetIdFn<Rsc, Scroll> where Rsc: HasEvents {
|
||||
/// Wrap this widget in a [`ScrollArea`] that pans along `axis`, with
|
||||
/// the wheel and a finger drag both registered -- how anything with a
|
||||
/// fixed layout becomes scrollable.
|
||||
///
|
||||
/// `pin` says which end the area opens at and clings to as its content
|
||||
/// grows, and it is spelled out rather than defaulted because the two
|
||||
/// cases are not variations on each other: a composer wants the end,
|
||||
/// where what is being typed is, and a code fence opened at the end of
|
||||
/// its longest line, which is the middle of a word (seen in
|
||||
/// `iris/run-headless.sh phone`, 2026-09-08).
|
||||
///
|
||||
/// One method with the axis and the pin passed in, rather than the
|
||||
/// three named variants this used to be (Iris, 2026-09-08: "can we
|
||||
/// make both scroll methods become `.scrollable`, and it takes an axis
|
||||
/// and a pin instead of having two?"). A code fence pans across its
|
||||
/// own long lines exactly the way a transcript pans down its rows, so
|
||||
/// the two are one mechanism with the direction passed in --
|
||||
/// `DragArbiter::on` is the other half.
|
||||
///
|
||||
/// A [`LazySpan`] has an inherent `scrollable` of its own that this
|
||||
/// does not reach: it owns a controller already and must not be
|
||||
/// wrapped in an area that would slide it about as a lump.
|
||||
fn scrollable(self, axis: Axis, pin: Pin) -> impl WidgetIdFn<Rsc, ScrollArea> where Rsc: HasEvents {
|
||||
move |state| {
|
||||
Scroll::new(self.add_strong(state), Axis::Y)
|
||||
.on(CursorSense::Scroll, |ctx, rsc| {
|
||||
let delta = ctx.data.scroll_delta.y * 50.0;
|
||||
ctx.widget(rsc).scroll(delta);
|
||||
})
|
||||
.add(state)
|
||||
let area = ScrollArea::new(self.add_strong(state), axis, pin);
|
||||
scroll_senses(area, axis)(state)
|
||||
}
|
||||
}
|
||||
|
||||
fn masked(self) -> impl WidgetFn<Rsc, Masked> {
|
||||
move |state| Masked {
|
||||
shape: None,
|
||||
inner: self.add_strong(state),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip to `shape` rather than to a plain box: `shape` is drawn
|
||||
/// behind this widget, filling the same region, and what clips is the
|
||||
/// primitive it drew -- so a rounded background and the corner its
|
||||
/// content is cut to are one rect, with no radius passed twice.
|
||||
/// Replaces `.masked().background(w)`, which drew the two but clipped
|
||||
/// to the box.
|
||||
fn masked_by<T>(self, shape: impl WidgetLike<Rsc, T>) -> impl WidgetFn<Rsc, Masked> {
|
||||
move |state| Masked {
|
||||
shape: Some(shape.add_strong(state)),
|
||||
inner: self.add_strong(state),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user