Prune commentary and stale Rust port notes

This commit is contained in:
iris committed 2026-09-10 00:44:13 -04:00
1 parent 3ae034a47b
commit 1e6d3b1edd
84 files changed
+334 -5648

No files matched your search

-21
View File
@@ -1,8 +1,3 @@
//! 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::*;
@@ -22,7 +17,6 @@ fn a_named_widget_reaches_the_tree_with_its_role_and_bounds() {
.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
@@ -59,10 +53,6 @@ fn a_widget_with_no_label_never_reaches_the_tree() {
);
}
/// 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 {
@@ -86,17 +76,10 @@ fn bounds_follow_a_moved_widget_and_updates_stay_incremental() {
.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");
@@ -110,10 +93,6 @@ fn bounds_follow_a_moved_widget_and_updates_stay_incremental() {
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"
-18
View File
@@ -1,16 +1,3 @@
//! 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::{
@@ -37,11 +24,6 @@ impl ActivationHandler for AndroidAccessSource<'_> {
}
}
/// 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) {}
-51
View File
@@ -1,19 +1,3 @@
//! `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,
@@ -50,25 +34,6 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
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];
@@ -219,10 +184,6 @@ impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
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)
}
@@ -235,11 +196,6 @@ impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
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();
@@ -267,9 +223,6 @@ impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
};
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;
@@ -278,8 +231,6 @@ impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
}
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
}
@@ -311,8 +262,6 @@ impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
}
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
}
}
+1 -48
View File
@@ -1,25 +1,3 @@
//! 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::{
@@ -45,37 +23,14 @@ pub struct Insets {
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.
/// Exposed in diagnostics to distinguish missing callbacks from zero insets.
pub updates: u64,
}
@@ -125,8 +80,6 @@ extern "system" fn apply_window_insets<'local>(
};
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);
}
-14
View File
@@ -1,17 +1,3 @@
//! 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;
-6
View File
@@ -22,16 +22,10 @@ impl<T: HasAndroidUiState> OpenUrl for T {
}
}
/// `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(()) => {}
-110
View File
@@ -48,9 +48,6 @@ pub struct AndroidRenderer {
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,
@@ -59,12 +56,6 @@ pub struct AndroidRenderer {
/// `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.
@@ -82,48 +73,17 @@ pub struct AndroidRenderer {
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
@@ -182,10 +142,6 @@ impl AndroidRenderer {
.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(),
@@ -200,14 +156,6 @@ impl AndroidRenderer {
)
})?;
// 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| {
@@ -263,12 +211,6 @@ impl AndroidRenderer {
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,
@@ -333,16 +275,6 @@ impl AndroidRenderer {
)
}
/// 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,
@@ -388,14 +320,6 @@ impl AndroidRenderer {
})
}
/// 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();
@@ -409,30 +333,10 @@ impl AndroidRenderer {
}
}
/// 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 two parts of it that
/// are waits rather than work: the `get_current_texture` acquire and
/// `queue.submit` + `present()`. The caller
/// (`android::view::render`) times the whole frame and fills in
/// `FrameParts::total`, so whatever is left over is iris's own work.
///
/// **The acquire is why this returns two numbers and not one.**
/// `get_current_texture` blocks until the compositor hands back a
/// swapchain image, which on an app comfortably ahead of the display
/// is most of every frame -- so counting it as CPU work (which this
/// did until 2026-09-09) reports a fling as milliseconds of iris
/// being slow when they are milliseconds of iris waiting its turn.
///
/// RUST.md's I5 "Where iris's frame time goes" diagnosis, added
/// 2026-09-05 -- see `iris_core::FrameParts`'s own doc for the caveat
/// the submit half shares: `present()` is not fenced against the GPU
/// actually finishing, so it is "how long the CPU was blocked handing
/// the frame off", not confirmed GPU time.
pub fn draw(&mut self) -> FrameParts {
let acquire_start = Instant::now();
let output = match self.surface.get_current_texture() {
@@ -499,20 +403,6 @@ impl AndroidRenderer {
/// 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,
-251
View File
@@ -11,10 +11,6 @@ use android_view::{
},
ndk::event::{Axis, Keycode, MotionAction},
};
// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the
// `Sized` *widget* (`widget::position::sized::Sized`), and an unqualified
// glob import shadows the language prelude -- `default/mod.rs` has the same
// explicit import for the same reason.
use std::{
cell::RefCell,
marker::{PhantomData, Sized},
@@ -62,43 +58,9 @@ pub struct AndroidUiState {
/// path -- see `android/insets.rs` for why they need a registry of
/// their own.
shared: Rc<RefCell<Shared>>,
/// I4 (RUST.md): pushed from `IrisViewPeer::render` and consulted by
/// the `AccessibilityNodeProvider` impl below; see `android/access.rs`
/// for the abort mitigation every `raise` on it goes through.
pub access_adapter: AccessAdapter,
/// The AccessKit tree itself -- see `iris_core::AccessTree`'s doc
/// comment.
pub access: AccessTree,
/// iris's own frame-time report (RUST.md's I5 box, "Measurements
/// taken" (b)) -- `render()` below records into it once per frame,
/// because `dumpsys gfxinfo` cannot see a `SurfaceView`'s own
/// GPU-drawn frames at all. See `iris_core::FrameReport`'s own doc.
pub frame_report: FrameReport,
/// `DisplayMetrics.density` (`new_peer`'s doc comment): physical pixels
/// per dp on this device, read once at view construction and carried
/// on `UiRenderState::density` (`render.set_density`, `new_peer`) from
/// then on -- every `Len::dp` in the widget tree resolves against it at
/// layout time (`Len::dp`'s field doc, IRIS_TODO.md's
/// "density-independent length unit" item, 2026-09-06).
///
/// **Everything else in this module is physical pixels, matching the
/// real wgpu surface/swapchain resolution** -- window size, touch
/// coordinates, insets. That is a correction from an earlier version
/// of this comment, which had `window_size`/`surface_changed`'s
/// `UiRenderState::resize` call divide by `content_scale` into a
/// *logical* coordinate space instead, as a global stopgap for
/// RUST.md's P0 box's phone report ("text is far too small"). That
/// stopgap fixed the size but not the *sharpness*: dividing to logical
/// units meant a `16.0`-sized glyph rasterised at 16 physical px and
/// then implicitly upscaled ~3x by the NDC mapping onto the real
/// physical framebuffer -- the exact "blurry ... glyphs drawn at
/// logical size and stretched by the scale" Iris reported next.
/// Resolving `dp` at layout time replaces it: a widget author writes
/// `dp(16)` for a size that should look the same physical size on any
/// density, and everything downstream (layout, hit-testing, the window
/// uniform, and the font size handed to the text shaper) works in the
/// display's own physical pixels throughout, so nothing is
/// rasterised at one resolution and displayed at another.
pub content_scale: f32,
/// The last insets `render()` saw -- compared each frame so
/// `AndroidAppState::on_insets_changed` fires only when they actually
@@ -131,13 +93,6 @@ impl AndroidUiState {
self.shared.borrow().insets
}
/// The insets state as one line for a diagnostics pane, including how
/// many times the platform has delivered any -- see
/// `insets::Shared::updates` for why the count is the load-bearing
/// part. `dispatches=0` says the listener has never run and the
/// numbers beside it are defaults rather than measurements, which is
/// the distinction a screenshot otherwise cannot make (UI_RULES.md,
/// "design the unknown state first").
pub fn insets_report(&self) -> String {
let shared = self.shared.borrow();
let i = shared.insets;
@@ -167,39 +122,12 @@ pub trait HasAndroidUiState: Sized + 'static {
pub trait AndroidAppState: HasAndroidUiState {
fn new(ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self;
/// The system back gesture/button. `true` means handled -- nothing
/// further happens; `false` lets the activity finish as it would with
/// no view at all. The default declines, since most screens have
/// nothing to intercept it for.
#[allow(unused_variables)]
fn back_pressed(&mut self, rsc: &mut AndroidRsc<Self>, render: &mut UiRenderState) -> bool {
false
}
/// Called once, right after `new`, with a fresh `JavaVM` handle and a
/// global reference to this app's own `View` -- for a caller that
/// needs to call into Java itself beyond what a [`RequestRedraw`]
/// handle already covers (P0's bench build calling
/// `BatteryManager`/`ClipboardManager` through the view's `Context`,
/// docs/RUST.md). Not folded into `new` itself: most implementors need
/// nothing here, and `new`'s job is building the widget tree, not
/// holding a platform handle -- the default does nothing. `vm`/`view`
/// are independent handles from the ones `new_peer` keeps for its own
/// `RequestRedraw` (a fresh `get_java_vm`/`new_global_ref` each), so
/// storing them has no effect on that mechanism.
#[allow(unused_variables)]
fn platform_ready(&mut self, rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {}
/// Called from `render()` whenever `AndroidUiState::insets()` differs
/// from what it was last frame -- once at startup for the status bar
/// (RUST.md's P0 box: "the status-bar inset is not applied" reported
/// the two top buttons sitting under it, because nothing read `.top`
/// at all), and again on a rotation or the keyboard opening/closing.
/// `insets` is in the same physical-pixel units everything else in the
/// tree now uses (`AndroidUiState::content_scale`'s field comment), so
/// a widget can add it to a layout size directly -- `dp(...) +
/// abs(insets.top)` if the widget wants a density-independent size
/// plus the system bar's own (already-physical) height. The default
/// does nothing -- most screens have no chrome that sits under a
/// system bar.
#[allow(unused_variables)]
fn on_insets_changed(&mut self, rsc: &mut AndroidRsc<Self>, insets: WindowInsets) {}
}
@@ -238,11 +166,6 @@ impl WindowInsets {
}
}
/// The android-view analogue of `default::DefaultRsc` -- identical in
/// substance, since none of `UiRsc`/`HasEvents`/`HasTasks`/`HasWidgetState`
/// mention winit. Kept as a separate type rather than shared code because
/// the two backends' `ViewPeer`/`ApplicationHandler` entry points hold
/// their harness state differently (see RUST.md's I2).
pub struct AndroidRsc<State: 'static> {
pub ui: UiData,
pub events: EventManager<Self>,
@@ -379,11 +302,6 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
pub(super) fn after_input(&mut self, ctx: &mut CallbackCtx) {
self.run_input_frame(ctx);
// RUST.md's P0 box, "doesn't enter it until I hit space, and also
// doesn't move cursor forward": Gboard needs `updateSelection`
// after every edit to keep its own model of the field in sync, or
// it holds keystrokes back rather than trusting a screen it
// believes is stale. See `update_ime_selection`'s own doc.
self.update_ime_selection(ctx);
let ui_state = self.state.android_state_mut();
@@ -393,16 +311,6 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
}
}
/// The view's one clock, anchoring it on `nanos` if nothing has yet
/// -- see the `device_clock` field. Either source may be the first to
/// arrive: a frame callback fires before any touch on an app that
/// animates at startup, and a touch arrives first on one that does
/// not.
///
/// `oldest` is the earliest sample the anchoring event carries, which
/// matters only when this is the call that anchors -- see
/// `DeviceClock::anchored`. A frame time carries no batch, so it
/// passes its own time for both.
fn device_clock(&mut self, event_time: i64, oldest: i64) -> DeviceClock {
*self
.device_clock
@@ -417,20 +325,6 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
}
}
/// The `log::debug!` calls here are a live diagnostic for a still-open
/// finding (RUST.md's I2): layout runs and reports the right pixel
/// region for the root (confirmed via `window_region`, logged below),
/// and the clear colour reaches the screen (confirmed by swapping it to
/// magenta and screenshotting), but no primitive ever appears on top of
/// it -- on both the Vulkan/SwiftShader and GLES/virgl backends. Leave
/// these in until that is root-caused; removing them loses the exact
/// evidence a `logcat` capture needs to reproduce the state. Gated on
/// `iris::diagnostics::trace_enabled` since 2026-09-07 (docs/RUST.md's
/// review, D1): unconditional, they were two `debug!` lines every
/// rendered frame, and `client_core::log_ring`'s `RingLogger` records
/// every level the app's already-`Debug` install lets through
/// regardless of target, so they filled the whole ring in under ten
/// seconds at 120Hz and left `Copy report` nothing else to show.
fn render(&mut self, ctx: &mut CallbackCtx, now: Instant) {
if self.state.android_state().renderer.is_none() {
return;
@@ -444,12 +338,6 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
let current_insets = ui_state.insets();
if current_insets != ui_state.last_insets {
let physical = WindowInsets::from_physical(current_insets);
// One line per real insets change. Iris's phone is the only
// place several of these bugs reproduce and `adb logcat` is
// the only instrument there (this-machine-android: system
// tracing is broken on that device), so the numbers a layout
// is actually fed have to reach the log -- "the composer
// floats at launch" is unanswerable from a screenshot alone.
log::info!(
"iris insets: left={} top={} right={} bottom={} ime_bottom={} \
ime_visible={} window={:?}",
@@ -465,14 +353,6 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
self.state.on_insets_changed(&mut self.rsc, physical);
}
// Gated the same way `iris::frame`'s own line is (docs/RUST.md's
// "Phone logging" review, D1): a bare `log::debug!` reaches
// `client_core::log_ring`'s ring regardless of level, since
// `RingLogger::enabled` is unconditionally `true` and the app
// installs at `LevelFilter::Debug` -- two of these a rendered
// frame filled the 2000-line ring in under ten seconds at 120Hz,
// leaving `Copy report` nothing but frame spam. See
// `iris::diagnostics`'s module doc.
if crate::diagnostics::trace_enabled() {
let ui_state = self.state.android_state();
log::debug!(
@@ -488,35 +368,8 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
self.window_size(),
);
}
// iris's own frame-time report (RUST.md's I5 box, "Measurements
// taken" (b)): started here, at the same point a redraw request
// fires, and stopped after `renderer.draw()`'s `queue.submit` +
// `present()` -- the span Compose's render report and `gfxinfo`
// both count. See `iris_core::FrameReport`'s own doc for exactly
// what this does and does not measure.
let frame_start = Instant::now();
// Anything moving on its own -- today a `LazySpan` coasting
// through a fling -- is advanced here, before the draw. **On
// `now`, not on `frame_start`**: `now` is the vsync the
// `Choreographer` handed this callback, which is evenly spaced,
// while `frame_start` is whenever the callback actually got to
// run. The frames are *presented* on the even cadence either way,
// so sampling the animation on the uneven one moves the content by
// an uneven distance per frame -- a fling that shimmers with no
// frame late enough to show up in a report. See
// `UiData::tick_animations` and `sense::DeviceClock`;
// `default/mod.rs`'s `RedrawRequested` arm is the winit half.
let animating = self.rsc.ui.tick_animations(now);
// **Asked for before the work, not after it.** A frame callback is
// one-shot, so an animation that wants another frame has to say so
// every frame -- and `Choreographer.postFrameCallback` schedules
// for the next vsync *after the call*. Asking at the end of this
// function meant any frame whose work ran past the vsync boundary
// (the swapchain acquire below alone can sit most of a frame)
// registered too late for the next one and got the one after --
// so one frame over budget silently cost a second frame as well.
// Unlike `after_input`, which only has to ask when input dirtied
// something.
if animating {
ctx.view.post_frame_callback(&mut ctx.env);
}
@@ -527,14 +380,6 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
return;
};
let frame_diagnostics = renderer.update(&mut self.rsc.ui, &mut self.render);
// First `DIAGNOSTIC_FRAMES` frames after each `surface_changed`
// only -- 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 a report from that window is what
// would show whether an atlas grow, a masks/move_offsets resize, or
// a fresh wgpu error coincided with it. `frame_count()` was just
// incremented inside `update()`, so `<=` counts frame 1 through
// `DIAGNOSTIC_FRAMES` inclusive.
if renderer.frame_count() <= DIAGNOSTIC_FRAMES {
log::info!(
"iris frame diagnostics: frame={} masks_resized={} moves_resized={} \
@@ -549,9 +394,6 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
}
let mut parts = renderer.draw();
parts.total = frame_start.elapsed();
// Dated on `now` -- the vsync this frame was for -- so the gap
// between consecutive frames is the display's own cadence and
// `PhaseStats::missed` counts vsyncs nothing was drawn for.
self.state
.android_state_mut()
.frame_report
@@ -570,12 +412,6 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
);
}
// I4 (RUST.md): only produces a `TreeUpdate` -- and so only queues
// anything to raise -- when the named set actually changed this
// frame; see `AccessTree`'s doc comment. Deferred rather than
// raised inline so it runs after this callback releases whatever
// it's holding, matching android-view's own demo and `raise`'s own
// contract.
let ui_state = self.state.android_state_mut();
if let Some(tree_update) =
ui_state
@@ -597,19 +433,6 @@ fn show_soft_input<'local>(env: &mut JNIEnv<'local>, view: &View<'local>) {
imm.show_soft_input(env, view, 0);
}
/// Replaces the activity's content with a plain, selectable, scrollable
/// text view holding `report` -- the on-screen half of `surface_changed`'s
/// renderer-failure path (UI_RULES.md: "a failure is reported where it
/// happened, and says what to do next," here "copy this and send it").
/// Goes through an ordinary instance method on the Java side
/// (`IrisView.showRendererError`) rather than a new `native` method: this
/// call is Rust reaching *into* Java, the opposite direction from every
/// `native fn` android-view/`IrisView` declare, and an ordinary virtual
/// call resolves against `ctx.view`'s real runtime class (`IrisView`) the
/// same way any other JNI method call here does. Silently does nothing on
/// any JNI failure -- there is no more-fallback screen to fall back to,
/// and the `log::error!` in `surface_changed` already reached logcat
/// first.
fn show_renderer_error<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, report: &str) {
let Ok(message) = env.new_string(report) else {
return;
@@ -703,18 +526,6 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
let trace_input = crate::diagnostics::trace_enabled();
let mut historical_ms: Vec<(u64, f32, f32)> = Vec::new();
// **Historical samples first.** A flick on a 120Hz screen is
// delivered as one or two `MotionEvent`s with the intermediate
// positions batched inside them, so reading only `x()`/`y()` threw
// away every sample but the last: the velocity tracker saw one
// `Pan` for the whole gesture, `VelocityTracker::velocity` answers
// 0.0 below two samples, and the release therefore flung at zero --
// Iris's phone, twice ("fling still doesn't work"), while a
// `ui-trace` swipe, which is many evenly-spaced events, flung fine.
// Replayed one at a time through the sensors rather than summarised,
// so the arbiter, the tracker and any other sensor all see the same
// motion the finger actually made; only the last sample ends the
// frame (`after_input`).
if matches!(action, MotionAction::Move) {
// Android documents the historical samples as oldest first and
// the event's own sample as the newest of the batch; everything
@@ -757,15 +568,6 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
ui_state.cursor.pos = vec2(x, y);
ui_state.cursor.buttons.left.update(false);
}
// A cancel ends the press -- a release that never arrives
// leaves whichever widget took pointer capture holding it
// forever -- but it is **not** a release, and saying so is
// `CursorState::cancelled`. It used to take the `Up` arm, so
// the system's own swipe up from the bottom edge to leave the
// app (moves, then `ACTION_CANCEL`) reached iris as a flick
// released at speed, and the transcript flung while the app
// was in the background: Iris's 2026-09-08 "leaving and
// reopening the app also randomly moved the vertical scroll".
MotionAction::Cancel => {
ui_state.cursor.pos = vec2(x, y);
ui_state.cursor.buttons.left.update(false);
@@ -837,25 +639,6 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
// in particular) mean something different from a `rest`-based one.
self.render.resize((width as f32, height as f32));
// **Reuse the existing renderer (device, atlas, buffers, bind
// groups) when one is already live -- only reconfigure the
// surface.** `surfaceChanged` fires on *every* size or format
// change, not only on a genuinely new `Surface`/window: showing
// the IME under `adjustResize` resizes the same `SurfaceView` and
// is reported through this exact callback. Rebuilding the whole
// `AndroidRenderer` here used to mean a fresh `UiRenderNode::new`
// -- a brand-new, empty glyph atlas and fresh GPU buffers -- while
// `iris_core`'s CPU-side glyph cache (`primitive/text.rs`) kept the
// atlas coordinates it had already handed out against the *old*
// atlas. Every glyph then drew from a UV rectangle that pointed
// into a texture that had just been recreated empty, so text
// vanished on the first keyboard open while rects (which never go
// through the atlas) kept drawing -- exactly the "rectangles stay,
// glyphs disappear" Iris reported. Confirmed by reading this path
// end to end (no fresh-atlas rebuild anywhere in `resize()` below,
// only in `AndroidRenderer::new`) before changing anything, per
// AGENTS.md's "verify before finishing".
//
// `AndroidRenderer::resize` only reconfigures the wgpu surface and
// rewrites the window uniform -- device, atlas, buffers and bind
// groups are untouched, so the glyph cache's coordinates stay
@@ -881,17 +664,6 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
}
let window = holder.surface(&mut ctx.env).to_native_window(&mut ctx.env);
// `AndroidRenderer::new` used to panic here through wgpu's own
// default uncaptured-error handler on a bind-group-layout
// validation failure -- exactly what aborted the P0 bench APK on
// Iris's phone with the message truncated to "wgpu error:
// Validation Error" and nothing else recoverable from the crash
// report (RUST.md's P0 box, "iris bench crash on the phone,
// 2026-09-06"). It now returns the full diagnostic instead; this is
// the one place in the app that can turn it into something a
// person can read, since `ctx.view`/`ctx.env` (needed to reach the
// Java side) are only in scope inside a `ViewPeer` callback.
//
// `content_scale` reaches `AndroidRenderer` only for the
// Diagnostics page's report text now -- window size and the
// shader's window uniform are physical pixels throughout (see the
@@ -930,24 +702,7 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
self.render(ctx, Instant::now());
}
Err(report) => {
// One line for logcat (UI_RULES.md: "the full text for
// whoever can read the log" lives here), the multi-line
// original on screen -- `show_renderer_error` below.
log::error!("iris renderer init failed: {}", report.replace('\n', " | "));
// Deferred, not called directly: `Activity::setContentView`
// tears the old view hierarchy down synchronously, which
// fires `IrisView`'s own `onFocusChanged` before
// `setContentView` returns -- straight back into this same
// `IrisViewPeer` through `on_focus_changed` while
// `with_peer` (android-view's dispatch, `view.rs` upstream)
// still holds this peer's `RefCell` borrow for the
// `surface_changed` call in progress. Found by inducing a
// validation error and hitting `RefCell already borrowed`
// at exactly that reentrant call (RUST.md's P0 box).
// `push_dynamic_deferred_callback` runs after `with_peer`
// drops the borrow, which is what every other callback in
// this file that reaches into Java already relies on
// (`raise_if_enabled`, above).
ctx.push_dynamic_deferred_callback(move |env, view| {
show_renderer_error(env, view, &report);
});
@@ -996,8 +751,6 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
/// one.
fn delayed_callback(&mut self, ctx: &mut CallbackCtx) {
self.drain_tasks();
// No vsync to date this one on -- it is a background task's
// "there is new state", not a frame the display asked for.
self.render(ctx, Instant::now());
}
@@ -1106,8 +859,6 @@ pub fn new_peer<'local, State: AndroidAppState>(
state: Default::default(),
_state: PhantomData,
};
// See `TextData::density`'s field doc for why this is set alongside
// `render.set_density` below rather than read from there.
rsc.ui.text.density = content_scale;
let shared = Rc::new(RefCell::new(Shared::default()));
let ui_state = AndroidUiState::new(shared.clone(), content_scale);
@@ -1116,8 +867,6 @@ pub fn new_peer<'local, State: AndroidAppState>(
let platform_view = env.new_global_ref(&view.0).unwrap();
state.platform_ready(&mut rsc, platform_vm, platform_view);
let mut render = UiRenderState::new();
// Every `Len::dp` in the tree resolves against this from now on -- see
// `UiRenderState::density`'s field doc and `Len::dp`'s.
render.set_density(content_scale);
let peer = IrisViewPeer {
rsc,
-53
View File
@@ -1,21 +1,7 @@
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
@@ -27,17 +13,9 @@ pub trait FocusHost {
/// 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);
@@ -45,18 +23,6 @@ pub fn recent_click(last_click: &mut Instant) -> bool {
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)
@@ -173,22 +139,6 @@ fn on_press(
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));
}
@@ -208,9 +158,6 @@ fn on_press(
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;
}
}
-10
View File
@@ -1,13 +1,3 @@
//! 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;
-3
View File
@@ -27,9 +27,6 @@ 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();
-2
View File
@@ -18,8 +18,6 @@ impl<T: HasDefaultUiState> FocusHost for T {
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(
PhysicalPosition::<f32>::from(region.top_left.tuple()),
PhysicalSize::<f32>::from(region.size().tuple()),
-27
View File
@@ -1,27 +1,3 @@
//! 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};
@@ -74,9 +50,6 @@ impl Log for StderrLogger {
}
}
/// 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 }));
-42
View File
@@ -39,12 +39,6 @@ pub type Proxy<Event> = EventLoopProxy<Event>;
/// 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,
@@ -67,16 +61,7 @@ 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,
}
@@ -228,12 +213,6 @@ 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().with_visible(false))
.unwrap();
@@ -342,12 +321,6 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
let frame_start = std::time::Instant::now();
let animating = rsc.ui_mut().tick_animations(frame_start);
let ui_state = state.default_state_mut();
// Asked for before the work rather than after it, the same
// way `IrisViewPeer::render` does and for the same reason
// -- see the longer comment there. winit coalesces
// repeated requests, so the only thing the order changes
// is whether the request is in before this frame's draw
// can push it past a vsync boundary.
if animating {
ui_state.window.request_redraw();
}
@@ -356,11 +329,6 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
let mut parts = ui_state.renderer.draw();
parts.total = frame_start.elapsed();
crate::diagnostics::log_frame(render, frame_start, parts, animating);
// 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);
}
@@ -369,16 +337,6 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
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
// (review, 2026-09-07) -- 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;
-11
View File
@@ -1,20 +1,11 @@
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", &[])
@@ -25,8 +16,6 @@ impl<T: HasDefaultUiState> OpenUrl for T {
.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}"),
}
}
-25
View File
@@ -68,12 +68,6 @@ impl UiRenderer {
let submit_start = Instant::now();
self.queue.submit(std::iter::once(encoder.finish()));
// 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);
FrameParts::waits(acquire, submit_start.elapsed())
@@ -83,7 +77,6 @@ impl UiRenderer {
self.config.width = size.width;
self.config.height = size.height;
self.surface.configure(&self.device, &self.config);
// 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,
@@ -117,16 +110,6 @@ impl UiRenderer {
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"
@@ -154,14 +137,6 @@ impl UiRenderer {
panic!("No usable GPU adapter for backends {backends:?}: {error}")
});
// 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!(
-34
View File
@@ -1,30 +1,3 @@
//! 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::Instant;
@@ -32,17 +5,10 @@ use iris_core::{FrameParts, 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)
}
-13
View File
@@ -4,16 +4,10 @@ use std::sync::Arc;
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 {}
@@ -44,13 +38,6 @@ impl<WL: WidgetLike<Rsc, Tag>, Rsc: HasEvents, Tag> Eventable<Rsc, Tag> for WL {
widget_trait! {
pub trait TaskEventable<Rsc: HasEvents + HasTasks>;
/// 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,
-77
View File
@@ -1,33 +1,3 @@
//! 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;
@@ -61,9 +31,6 @@ impl TouchAction {
}
}
/// 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",
@@ -76,27 +43,16 @@ impl TouchAction {
#[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() {
@@ -134,16 +90,11 @@ impl TouchScript {
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);
@@ -159,8 +110,6 @@ impl RequestRedraw for RedrawCounter {
}
}
/// 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>>,
@@ -171,7 +120,6 @@ pub struct HarnessState {
/// 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>,
}
@@ -296,8 +244,6 @@ pub struct Harness {
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,
}
@@ -345,15 +291,10 @@ impl Harness {
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);
@@ -376,9 +317,6 @@ impl Harness {
);
}
/// 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;
@@ -389,10 +327,6 @@ impl Harness {
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;
@@ -403,18 +337,11 @@ impl Harness {
}
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
@@ -423,10 +350,6 @@ impl Harness {
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);
-268
View File
@@ -1,16 +1,6 @@
//! Pass conditions for LAYOUT.md section 8, exercised as plain unit tests
//! rather than through `run-headless.sh`: `UiRenderState` and `Widgets` do
//! not touch a GPU or a window, so a tree can be built and driven directly.
//! No GPU-backed rendering (`UiRenderNode`) is exercised here -- only the
//! CPU-side layout/move machinery LAYOUT.md is about.
use crate::prelude::*;
use std::{cell::Cell, cell::RefCell, rc::Rc};
/// The minimal `UiRsc` a test needs: just the shared `UiData`, none of the
/// event/window/state plumbing `DefaultRsc` carries. `pub(crate)` so
/// `access_tests.rs` (I4, RUST.md) can reuse it rather than keeping a
/// second copy of the same harness.
pub(crate) struct TestRsc {
pub(crate) ui: UiData,
}
@@ -94,14 +84,6 @@ impl Widget for TracedParent {
}
}
/// Minimal reproduction for a container's child-layer cursor being retained
/// as though it were the layer on which the container itself was entered.
///
/// `Stack` is drawn by `Sized` on layer 0. It advances its painter to layers
/// 1 and 2 for its two children. The retained `ActiveData` must still say the
/// stack itself is on layer 0; otherwise an ordinary redraw of `Sized` asks
/// for the stack on 0 again and turns the invented 2 -> 0 change into a full
/// redraw of the stack and both children.
#[test]
fn a_widget_retains_its_entry_layer_not_its_child_cursor() {
let mut rsc = TestRsc {
@@ -198,10 +180,6 @@ fn a_parent_that_ignores_child_size_is_not_invalidated_with_it() {
assert_eq!(&*trace.borrow(), &["child"]);
}
/// A content-sized vertical container grows vertically when one child grows,
/// but that does not invalidate another child's retained height. Its width is
/// the context that could change that height (for example through wrapping),
/// and that stayed fixed.
#[test]
fn a_span_reuses_unchanged_sibling_sizes_when_only_its_along_extent_changes() {
let mut rsc = TestRsc {
@@ -223,9 +201,6 @@ fn a_span_reuses_unchanged_sibling_sizes_when_only_its_along_extent_changes() {
dir: Dir::DOWN,
gap: Len::ZERO,
});
// `Sized` settles its child from the full offered box into the content
// height, reproducing the retained-region change a nested content-sized
// row sees when one of its children grows.
let root = rsc
.ui
.widgets
@@ -264,9 +239,6 @@ fn a_child_coordinate_offset_moves_only_the_child_subtree() {
offset: vec2(0.0, 15.0),
});
let parent_weak = parent.weak();
// Keep the coordinate-owning widget below the root: a nested widget is
// normally redrawn when its parent visits it, which takes a different
// retained-state path from `UiRenderState::redraw` on the root itself.
let outer = rsc.ui.widgets.add_strong(Sized {
inner: parent.any(),
x: None,
@@ -285,8 +257,6 @@ fn a_child_coordinate_offset_moves_only_the_child_subtree() {
assert!((child_before.top_left.y - 15.0).abs() < 0.01);
rsc.ui.widgets.get_mut(&parent_weak).unwrap().offset.y = 35.0;
// Force the ordinary ancestor-redraw path rather than letting the
// renderer visit only the dirty descendant directly.
rsc.ui.widgets.get_mut(&outer_weak).unwrap().x = None;
render.update(&root, &mut rsc);
let (draws, rewrites, moves, _shapes) = render.take_counters();
@@ -332,13 +302,6 @@ fn a_hinted_rest_draws_once_and_only_moves_the_fixed_child_after_it() {
assert!((last.top_left.y - 260.0).abs() < 0.01, "{last:?}");
}
/// A `ScrollArea` over a `Span` of `n` fixed-height rects -- N primitives large
/// enough that an O(N) regression in the move path would show up as a
/// non-trivial counter rather than being lost in noise (LAYOUT.md section
/// 8, condition 3, using rects rather than glyphs to avoid pulling the font
/// stack into a plain unit test). Returns the scroll widget (weak, for
/// mutating it later), the erased root to draw, and the rows (weak, for
/// hit-testing one of them).
fn scrolled_rects(
rsc: &mut TestRsc,
n: usize,
@@ -348,9 +311,6 @@ fn scrolled_rects(
for _ in 0..n {
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
rects.push(rect.weak());
// Each row gets a fixed height so the span's total content is
// genuinely taller than the viewport -- rest-sized rows would just
// divide whatever space is offered and never need scrolling.
let row = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
@@ -362,14 +322,6 @@ fn scrolled_rects(
let scroll = rsc
.ui
.widgets
// Anchored at the *start*: every test below scrolls down from
// the top and states its sign convention against that. An
// end-anchored area now sits at its end from its first drawn
// frame (`Scroll::draw` measures and places in the same frame),
// so `Pin::End` here would mean scrolling down from a
// position that is already the bottom -- a clamped no-op, which
// reads as "the move path is broken" rather than as the test
// starting somewhere it did not mean to.
.add_strong(ScrollArea::new(span.any(), Axis::Y, Pin::Start));
let weak = scroll.weak();
(weak, scroll.any(), rects)
@@ -385,12 +337,6 @@ fn an_unchanged_frame_draws_and_rewrites_nothing() {
render.resize((800.0, 20000.0));
render.update(&root, &mut rsc);
// Two, not one: the first offers `ScrollArea`'s content the container's
// own length as a placeholder (nothing has been measured yet) and
// `Scroll::draw` asks to be drawn again once it knows the real one,
// which the second update is. Only after that is the tree settled --
// see `scrolling_moves_in_o1_without_a_redraw`'s own note on the
// same first draw.
render.update(&root, &mut rsc);
render.take_counters(); // discard the first, real draws
@@ -408,35 +354,15 @@ fn scrolling_moves_in_o1_without_a_redraw() {
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
// The first draw offers `ScrollArea`'s content a zero-height region
// (nothing has been measured yet) and learns the real content length
// from what comes back; `update()` only redraws widgets actually
// marked dirty, so that corrected length is not reflected in the
// content's own *active* region until something -- here a no-op
// scroll tick -- actually asks `ScrollArea` to redraw again. Only after
// that warm-up does the content's offered size stop changing between
// draws, which is what makes a further, real scroll tick a same-size
// move instead of a resize. See scroll.rs.
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
render.take_counters();
// Negative: `scroll`'s sign convention subtracts from `amt`, and
// `amt` starts at (and is clamped to) 0 at the top of the content, so
// a *positive* argument here would be scrolling further up (a no-op,
// already clamped) rather than actually moving anything.
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0);
render.update(&root, &mut rsc);
let (draws, _rewrites, moves, _shapes) = render.take_counters();
// The pass condition (LAYOUT.md section 8, condition 3) is 0 draws and
// 1 move_offsets write, independent of how many rects are in the
// scrolled subtree. `draws` here is exactly 1: `ScrollArea` itself is
// marked dirty by `scroll()` and its own body is cheap arithmetic with
// no primitives of its own, so it is the one real `Widget::draw` this
// counts -- the 500 rects underneath move via the O(1) chain and are
// never revisited.
assert_eq!(draws, 1, "only Scroll itself should redraw");
assert_eq!(moves, 1, "the scrolled subtree should move in one write");
}
@@ -463,25 +389,12 @@ fn hit_testing_follows_a_scrolled_widget() {
let before_px = before.to_px((800.0, 600.0).into());
let after_px = after.to_px((800.0, 600.0).into());
// Scrolling by -37 moves `amt` from 0 to 37, sliding the content's
// top-left up by 37px -- `resolved_region` (the CPU twin of the vertex
// shader's chain walk) must reflect that immediately, not the
// pre-scroll position, or a tap routed through it would land on
// whatever is now at the old coordinates instead of this widget.
assert!(
(after_px.top_left.y - (before_px.top_left.y - 37.0)).abs() < 0.01,
"before={before_px:?} after={after_px:?}"
);
}
/// `ActiveData::mask` is the mask a widget was drawn **under**, not the one
/// it set for itself -- `redraw` feeds it straight back in as the inherited
/// mask, so storing the set one hands a `Masked` its own mask the second
/// time round -- which `Painter::set_mask` asserts against, since a mask
/// that chains to itself is a clip loop. That was an abort the first time
/// the composer's new scroll area was redrawn on the emulator; a targeted
/// redraw of a `Masked` is what any real screen does whenever anything
/// inside it changes.
#[test]
fn redrawing_a_masked_widget_does_not_nest_its_own_mask() {
let mut rsc = TestRsc {
@@ -536,27 +449,10 @@ fn a_mask_stays_put_while_its_scrolled_content_moves() {
let masked_slot_after = render.active.get(&masked_id).unwrap().move_slot;
let mask_delta_after = rsc.ui.move_offsets[masked_slot_after.idx()].delta;
// `Masked` itself is never the target of a `mov`/`place` here --
// only its scrolled child is -- so the slot its own mask references
// (`Painter::set_mask` bakes in `self.move_slot`, i.e. this one) must
// still read zero after the scroll. The visible counterpart of this
// (the clipped edge follows the scroll while the viewport border does
// not) is `iris/run-headless.sh`'s job to catch in a real frame; this
// is the numeric half, on the same data the fragment shader's
// `resolve_move` reads. See LAYOUT.md section 2b.
assert_eq!(mask_delta_before, [0.0, 0.0]);
assert_eq!(mask_delta_after, [0.0, 0.0]);
}
/// Reproduces `transcript_ui::composer::build_composer`'s exact tree shape
/// (a `Rect` background stacked behind a `Span::RIGHT`-wrapped, padded,
/// `rest`-width `TextEdit`, itself the second child of an outer
/// `Span::DOWN` beside a `rest(1)`-height sibling) without the event/
/// resource plumbing `composer.rs`'s builders need, to isolate whether the
/// bug Iris reported on 2026-09-06 ("text seems to not appear in box")
/// is this crate's layout engine or something specific to the real
/// composer/screen. `TextEditable::edit` only needs `UiRsc`, so a plain
/// insert exercises the exact redraw path a keystroke does.
fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget<TextEdit>, StrongWidget) {
let field = wtext("")
.editable(EditMode::MultiLine)
@@ -574,12 +470,6 @@ fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget<TextEdit>, StrongWidget)
(field, tree)
}
/// The reproduction itself. A window this tall stands in for the keyboard
/// closed; the second, shorter `resize` stands in for `adjustResize`
/// shrinking the surface when the IME opens -- exactly the sequence
/// `IrisViewPeer::surface_changed` drives on a real keyboard open. Typing
/// happens both before and after, since Iris's report was specifically
/// that text typed *after* the keyboard was already up did not appear.
#[test]
fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
let mut rsc = TestRsc {
@@ -590,11 +480,6 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
render.resize((1080.0, 2298.0));
render.update(&root, &mut rsc);
// Focusing a field is what places its caret on a real tap
// (`attr.rs`'s `on_press` -> `TextEditCtx::select`), and an insert
// with no caret is a routing bug rather than a state to simulate --
// `insert_str`'s own `debug_assert!` says so, and caught this test
// typing into an unfocused field when it was added.
field
.edit(&mut rsc)
.select(vec2(40.0, 2250.0), vec2(1080.0, 2298.0), false, false);
@@ -602,8 +487,6 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
render.update(&root, &mut rsc);
let before_px = render.window_region(&field, &rsc).unwrap();
// The field is one line plus 12dp of padding on a 2298-tall window --
// nowhere near the whole window's height, and anchored at the bottom.
assert!(
before_px.bot_right.y - before_px.top_left.y < 200.0,
"before a resize: {before_px:?}"
@@ -613,10 +496,6 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
"expected the bar near the bottom before a resize: {before_px:?}"
);
// The keyboard opens: a real `surface_changed`/`resize` to a shorter
// window, then a further keystroke -- the redraw that must land in the
// bar's new (also short) region, not whatever region a provisional
// provisional placement used along the way.
render.resize((1080.0, 1478.0));
render.update(&root, &mut rsc);
field.edit(&mut rsc).insert("b");
@@ -633,13 +512,6 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
);
}
/// `ScrollArea` used to be documented as resolving its own lengths against
/// `Painter::output_size` -- the window -- which read as if a scroll area
/// smaller than the screen could not work, and cost a session's
/// investigation before the composer was wired up (docs/RUST.md,
/// 2026-09-06). It measures `painter.px_size()` now, so this pins the
/// three numbers that follow from the offered box: what it reports
/// upward, what its capping parent reports, and how far it can pan.
#[test]
fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
let mut rsc = TestRsc {
@@ -654,8 +526,6 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
let scroll = rsc
.ui
.widgets
// Start-anchored, so the `scroll(-37.0)` below has somewhere to
// go -- see `scrolled_rects`' note on the same choice.
.add_strong(ScrollArea::new(tall.any(), Axis::Y, Pin::Start));
let scroll_w = scroll.weak();
let scroll_id = scroll.id();
@@ -669,17 +539,10 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
// Two passes: the first offers the content a zero-length region
// (nothing measured yet) and learns the real content length from what
// comes back -- see `scrolling_moves_in_o1_without_a_redraw` for why
// that warm-up is deliberate rather than a bug.
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
// Reports the *content*, so the cap above it has something to cap;
// reporting the container instead would make the answer a function of
// itself, since the container is sized from this very number.
assert_eq!(
render.active.get(&scroll_id).unwrap().size.y,
Len::abs(1000.0)
@@ -690,10 +553,6 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
"the cap, not the content and not the window"
);
// Panning is bounded by content minus *container*: 900, not the 400
// a 600px window would give. The draw is what spends the delta -- a
// controller banks it until the layout that knows where the content
// ends (`ScrollController::take_delta`).
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-10_000.0);
render.update(&root, &mut rsc);
assert!(
@@ -703,13 +562,6 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
);
}
/// The half `hit_testing_follows_a_scrolled_widget` could not see: it
/// checks a *descendant* of the widget `ScrollArea` actually moves, whose own
/// `region` is stale and is corrected entirely by the move chain. The
/// moved widget itself had its `region` updated *and* the chain delta
/// added on top, so its hit box sat at twice the pan -- which is why a
/// finger pan of the composer left its field untappable. See
/// `ActiveData::move_applied`.
#[test]
fn a_panned_widgets_own_hit_box_moves_exactly_once() {
let mut rsc = TestRsc {
@@ -725,8 +577,6 @@ fn a_panned_widgets_own_hit_box_moves_exactly_once() {
let scroll = rsc
.ui
.widgets
// Start-anchored, so the `scroll(-37.0)` below has somewhere to
// go -- see `scrolled_rects`' note on the same choice.
.add_strong(ScrollArea::new(tall.any(), Axis::Y, Pin::Start));
let scroll_w = scroll.weak();
let root = scroll.any();
@@ -748,16 +598,6 @@ fn a_panned_widgets_own_hit_box_moves_exactly_once() {
);
}
/// A `Masked` used to allocate a **new** mask slot on every draw, and
/// `draw_inner`'s unchanged-region fast path means its descendants are
/// mostly *not* redrawn with it -- so they went on referencing the slot
/// they were first drawn under, whose region had since stopped being the
/// widget's. Measured 2026-09-06 on the composer's tree: four live mask
/// entries, none of them the `Masked`'s current box, and the field it was
/// meant to clip drew nothing at all on the emulator. The slot is
/// allocated once and rewritten in place now (`ActiveData::own_mask`), so
/// this pins both halves: one entry, and that entry is the widget's own
/// region.
#[test]
fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
let mut rsc = TestRsc {
@@ -769,9 +609,6 @@ fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
inner: inner_root,
});
let masked_id = masked.id();
// Placed at the bottom of a `Span::DOWN` behind a `rest(1)` sibling,
// which is what moves the bar away from the provisional slot it is
// first drawn at -- the move that left the stale mask behind.
let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK));
let filler = rsc.ui.widgets.add_strong(Sized {
inner: filler.any(),
@@ -808,14 +645,6 @@ fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
);
}
/// A `dp` cap that has done its job must be reported in pixels. `Span`
/// places a child using the `abs`/`rel` of the length it reported, so a
/// `MaxSize` handing back the caller's own `dp(168)` gave the composer's
/// bar a slot of **zero** the moment its content grew past six lines --
/// and the `ScrollArea` inside then measured its container at -63px (the
/// padding, subtracted from nothing) and panned the whole message out of
/// view. Measured on this checkout's emulator, 2026-09-06:
/// `container=-63 content=415.8 amt=478.8`. See `Len::fold_dp`.
#[test]
fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() {
let mut rsc = TestRsc {
@@ -858,15 +687,6 @@ fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() {
);
}
/// The sibling of `a_panned_widgets_own_hit_box_moves_exactly_once`, on
/// the branch that fix had no reason to touch: `draw_inner`'s
/// size-independent fast path rewrites a widget's primitives *in place*
/// and leaves its move slot alone, so unlike `mov` there is no slot delta
/// for `region` to have absorbed. Counting one there anyway makes
/// `resolved_region` subtract a delta the chain never held, and the
/// widget's hit box lands short of where it is drawn by exactly the
/// distance it just moved -- with nothing on screen to say so, since the
/// primitives are in the right place.
#[test]
fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at() {
let mut rsc = TestRsc {
@@ -879,9 +699,6 @@ fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at(
y: Some(Len::abs(100.0)),
});
let spacer_w = spacer.weak();
// `Rect` is `is_size_independent`, so growing the spacer above it
// offers this one a region that changed *both* position and size --
// the one shape that reaches the branch under test.
let below = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let below_w = below.weak();
let mut span = Span::empty(Dir::DOWN);
@@ -909,17 +726,9 @@ fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at(
);
}
/// A parent that both `mov`s a child (its own layout moved the box it
/// offers) and places it inside that box in the same frame -- what
/// `LazySpan::place`'s Bottom-known branch does once a row's cached height
/// stops matching what the row reports, which is reachable as soon as a
/// transcript row's blocks wrap (docs/IRIS_TODO.md's "Found by P1a").
struct MoveThenPlace {
inner: StrongWidget,
/// Where the child is *offered* a (constant-size) box, moved between
/// frames by the test.
offer_top: f32,
/// Where the child is then placed within this widget's own region.
place_top: f32,
}
@@ -945,7 +754,6 @@ impl Widget for MoveThenPlace {
}
}
/// Placement must preserve a move already applied in the same frame.
#[test]
fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement() {
let mut rsc = TestRsc {
@@ -975,10 +783,6 @@ fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement
"the child should be drawn where it was placed, not where it was offered: {before:?}"
);
// Move the offered box without changing its size (the `mov` fast path)
// and place the child at the same spot as before. Marking the parent
// dirty is what a real container's own content change does; the child
// itself is untouched, which is the case `mov` exists for.
{
let parent = rsc.ui.widgets.get_mut(&parent_w).unwrap();
parent.offer_top = 200.0;
@@ -993,22 +797,8 @@ fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement
);
}
// ---------------------------------------------------------------------
// LAYOUT.md's "Masks with a shape" -- its pass conditions, at layer 1.
//
// The shape a mask clips to is a *primitive already drawn*, never a copy
// of one, so "the child's clipped corner" and "the container's own corner"
// are the same arithmetic. These say so by evaluating both and demanding
// exact equality: an approximate assertion would also pass a second copy
// of the radius that merely happened to agree.
// ---------------------------------------------------------------------
const RADIUS: f32 = 20.0;
/// A rounded container with `.masked_by` it, holding a `Rect::REST` child
/// that fills it -- so the child's own corners are exactly the corners
/// being clipped away. Returns the drawn state, the mask, the child, and
/// the shape primitive the mask points at.
fn rounded_container(rsc: &mut TestRsc) -> (UiRenderState, MaskIdx, WidgetId, u32) {
let child = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let child_id = child.id();
@@ -1046,9 +836,6 @@ fn rounded_container(rsc: &mut TestRsc) -> (UiRenderState, MaskIdx, WidgetId, u3
(render, mask, child_id, slot)
}
/// The pass condition: the child's coverage at a corner pixel *equals*
/// the container's own coverage there. Exactly equal, because it is the
/// same primitive evaluated once -- LAYOUT.md's point 1.
#[test]
fn a_masked_child_is_clipped_by_its_container_s_own_corner() {
let mut rsc = TestRsc {
@@ -1062,12 +849,6 @@ fn a_masked_child_is_clipped_by_its_container_s_own_corner() {
.expect("a mask's shape is a rect")
.radius;
// Across the whole corner arc, not one point on it: a single sample
// is satisfied by a mask that clips to the box and happens to agree
// where the two coincide. Swept from the arc's own centre -- the
// straight chord between the two ends of the arc lies *inside* the
// circle everywhere, so a walk along it never leaves the shape and
// the `outside` count below is what caught that.
let arc_center = corners.top_left + Vec2::new(radius, radius);
let (mut outside, mut inside) = (0, 0);
for i in 0..=20 {
@@ -1095,9 +876,6 @@ fn a_masked_child_is_clipped_by_its_container_s_own_corner() {
);
}
/// A hit test asks the same question the pixels do: the corner the
/// container rounded away is not there to be pressed, and a point just
/// inside the curve is. LAYOUT.md's point 4.
#[test]
fn a_mask_s_shape_decides_what_can_be_pressed() {
let mut rsc = TestRsc {
@@ -1106,20 +884,16 @@ fn a_mask_s_shape_decides_what_can_be_pressed() {
let (render, mask, _child, slot) = rounded_container(&mut rsc);
let corners = render.primitive_corners(slot, &rsc);
// The very corner of the box, which the radius cut off.
let cut = corners.top_left + Vec2::new(1.0, 1.0);
assert!(
!render.mask_admits(mask, cut, &rsc),
"the corner the container rounded away is still pressable",
);
// The same distance in along the diagonal, past the curve.
let inside = corners.top_left + Vec2::new(RADIUS, RADIUS);
assert!(
render.mask_admits(mask, inside, &rsc),
"a point well inside the curve is not pressable",
);
// And the middle of an edge, which no radius touches -- the half the
// rounding had no reason to change.
let edge = Vec2::new(
(corners.top_left.x + corners.bot_right.x) / 2.0,
corners.top_left.y + 1.0,
@@ -1130,11 +904,6 @@ fn a_mask_s_shape_decides_what_can_be_pressed() {
);
}
/// Nested masks multiply, so a pixel inside two feathered corners is
/// dimmed by both -- LAYOUT.md's point 2, and the "alpha should be
/// decreased / multiplied" Iris asked for. Written as a product of the
/// two the shader would compute separately, which is what "multiply"
/// means and what an intersection test would get wrong.
#[test]
fn nested_masks_multiply_their_coverage() {
let mut rsc = TestRsc {
@@ -1182,8 +951,6 @@ fn nested_masks_multiply_their_coverage() {
rounded_rect_coverage(pos, c.top_left, c.bot_right, radius)
};
// A point on the corner arc, where both feathers are partial -- the
// only place a product and a minimum differ.
let slot = render.first_primitive(inner_shape_id).unwrap();
let corners = render.primitive_corners(slot, &rsc);
let pos = corners.top_left + Vec2::new(RADIUS * 0.3, RADIUS * 0.3);
@@ -1200,11 +967,6 @@ fn nested_masks_multiply_their_coverage() {
);
}
/// A plain `.masked()` -- no shape given -- still clips to the widget's
/// own box with square corners, which is what every list and scroll area
/// relies on. The half the shape work had no reason to touch, and the one
/// that would silently round every existing clip if `set_mask` ever wrote
/// a radius of its own.
#[test]
fn a_plain_mask_still_clips_to_a_square_box() {
let mut rsc = TestRsc {
@@ -1236,16 +998,6 @@ fn a_plain_mask_still_clips_to_a_square_box() {
);
}
/// A scroll area created to be *read* opens at the beginning of its
/// content, however many frames it takes to learn how long that content
/// is.
///
/// The bug this pins: `content_len` was `0.0` both for "nothing here" and
/// for "not drawn yet", so the first frame's clamp found a range of zero,
/// read `amt == len` as "sitting at the end", and set `snap_end` -- and
/// the frame after, now knowing the real length, jumped to it. On screen
/// that was a code fence opening at the end of its longest line, in the
/// middle of a word (`iris/run-headless.sh phone`, 2026-09-08).
#[test]
fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() {
for (name, pin, want) in [("read", Pin::Start, 0.0), ("written", Pin::End, 4900.0)] {
@@ -1267,10 +1019,6 @@ fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() {
let mut render = UiRenderState::new();
render.resize((800.0, 100.0));
// Twice: the first draw is the one that measures the content, and
// the defect only showed on the second. The touch in between is
// what asks for that second draw -- an unchanged frame draws
// nothing at all, which is the point of the frame before it.
render.update(&root, &mut rsc);
let _ = rsc.ui.widgets.get_mut(&weak);
render.update(&root, &mut rsc);
@@ -1283,17 +1031,6 @@ fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() {
}
}
/// docs/IRIS_TODO.md's "A `Span` of `Pad`ded children inside another
/// `Span` places those children a slot out of step", worked around in
/// `transcript-ui/src/tool.rs` by flattening the two spans into one --
/// which costs a tool group the inset its cards should sit inside.
///
/// The shape is the smallest one that reproduced it there: an outer
/// `Span(DOWN)` whose second child is another `Span(DOWN)` whose children
/// are each a `Pad` around a fixed-height rect. Each rect is asserted to
/// be *drawn* where its own box is -- `primitive_corners` rather than
/// `window_region`, since the report is about what is on screen and the
/// two resolve the move chain differently.
#[test]
fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
const PAD: f32 = 4.0;
@@ -1376,11 +1113,6 @@ fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
}
}
/// Growing an already-drawn row first measures its new child against the
/// row's old height. That provisional box can end before it starts when the
/// old trailing edge is above the new child's cursor. The size must bubble to
/// `LazySpan` and the corrected allocation must travel back down before this
/// update is presented; a later stream event is not a layout pass.
#[test]
fn a_new_child_in_a_growing_lazy_row_uses_its_final_box_immediately() {
const FIRST: f32 = 30.0;
-7
View File
@@ -7,13 +7,6 @@
#![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"))]
-19
View File
@@ -1,22 +1,3 @@
//! 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);
}
+41 -1002
View File
File diff suppressed because it is too large. Load diff
-149
View File
@@ -1,11 +1,3 @@
//! 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};
@@ -62,7 +54,6 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
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();
@@ -88,9 +79,6 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
});
}
// 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
@@ -130,15 +118,6 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
);
}
/// 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 {
@@ -146,8 +125,6 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
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();
@@ -159,10 +136,6 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
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;
}
@@ -187,8 +160,6 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
"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());
@@ -206,12 +177,6 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
);
}
/// 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 {
@@ -257,13 +222,6 @@ fn capturing_one_widget_starves_every_other_widget_of_events() {
);
}
/// 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 {
@@ -271,7 +229,6 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
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)
@@ -282,11 +239,6 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
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);
@@ -302,7 +254,6 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
"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());
@@ -313,8 +264,6 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
"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());
@@ -325,23 +274,13 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
"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()));
}
/// A defect found in review, 2026-09-07. 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 = DeviceClock::anchored(now, 12 * MS, 0);
assert_eq!(
@@ -361,9 +300,6 @@ fn the_first_events_batched_samples_are_dated_apart() {
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;
@@ -377,18 +313,6 @@ fn the_clock_orders_samples_across_events() {
);
}
/// 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 {
@@ -417,8 +341,6 @@ fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() {
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,
@@ -435,9 +357,6 @@ fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() {
"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!(
@@ -448,16 +367,6 @@ fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() {
);
}
/// 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 {
@@ -465,10 +374,6 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
events: EventManager::default(),
};
// The bystander contains the capturer on a lower visual layer: the
// shape of a vertical transcript scroller with a higher horizontal
// scroller inside one row. Both observe the undecided press, then only
// the recognizer matching its direction may consume it.
let capturer = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let capturer_weak = capturer.weak();
let bystander = rsc.ui.widgets.add_strong(Stack {
@@ -535,8 +440,6 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
"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);
@@ -554,16 +457,6 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
);
}
/// 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 [
@@ -579,23 +472,16 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() {
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
})
// The horizontal area is visually above the vertical one, as
// it is when a raised transcript row contains sideways content.
.layer_offset(1)
.scrollable(Axis::Y, Pin::Start)
.add_strong(&mut rsc);
@@ -607,8 +493,6 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() {
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);
}
@@ -639,24 +523,6 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_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 {
@@ -664,11 +530,6 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
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();
@@ -691,19 +552,12 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
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,
@@ -746,9 +600,6 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
);
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,
File diff suppressed because it is too large. Load diff
-5
View File
@@ -3,7 +3,6 @@ use crate::prelude::*;
pub struct Pad {
pub padding: Padding,
pub inner: StrongWidget,
/// Cleared after the reported size fits the offered region.
pub exact_region: bool,
}
@@ -130,10 +129,6 @@ impl 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.into())
-72
View File
@@ -1,13 +1,6 @@
//! `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 that moves one fixed child as a subtree.
pub struct ScrollArea {
inner: StrongWidget,
ctl: ScrollController,
@@ -72,23 +65,15 @@ impl Widget for ScrollArea {
}
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,
}
}
/// A content-sized box offset by the current scroll amount.
fn child_region(&self, content_len: f32) -> UiRegion {
let axis = self.ctl.axis();
let mut region = UiRegion::FULL;
@@ -105,22 +90,10 @@ mod tests {
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(),
@@ -149,17 +122,12 @@ mod tests {
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>,
@@ -180,8 +148,6 @@ mod tests {
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();
@@ -189,17 +155,13 @@ mod tests {
}
}
/// 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);
@@ -218,8 +180,6 @@ mod tests {
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,
@@ -232,7 +192,6 @@ mod tests {
"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,
@@ -243,9 +202,6 @@ mod tests {
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();
@@ -280,8 +236,6 @@ mod tests {
);
}
/// 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();
@@ -303,9 +257,6 @@ mod tests {
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();
@@ -327,10 +278,6 @@ mod tests {
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] {
@@ -345,9 +292,6 @@ mod tests {
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,
@@ -370,9 +314,6 @@ mod tests {
"{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);
@@ -397,15 +338,8 @@ mod tests {
}
}
/// 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);
@@ -429,10 +363,6 @@ mod tests {
}
}
/// 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();
@@ -457,8 +387,6 @@ mod tests {
"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,
-189
View File
@@ -1,34 +1,3 @@
//! 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;
@@ -38,43 +7,15 @@ use std::time::Instant;
/// 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,
@@ -85,9 +26,6 @@ impl Pin {
}
}
/// 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
@@ -95,8 +33,6 @@ impl Pin {
/// 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
@@ -105,67 +41,28 @@ pub struct Travel {
}
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
@@ -188,32 +85,18 @@ impl ScrollController {
}
}
/// 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;
}
@@ -221,13 +104,6 @@ impl ScrollController {
/// 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);
@@ -238,10 +114,6 @@ impl ScrollController {
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;
}
@@ -253,10 +125,6 @@ impl ScrollController {
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()
@@ -266,8 +134,6 @@ impl ScrollController {
}
}
/// 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
}
@@ -296,21 +162,6 @@ impl ScrollController {
/// 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)
}
@@ -338,14 +189,6 @@ impl ScrollController {
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);
@@ -378,15 +221,6 @@ impl ScrollController {
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();
@@ -400,8 +234,6 @@ impl ScrollController {
// 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
@@ -427,14 +259,10 @@ 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)
}
@@ -443,7 +271,6 @@ pub trait Scrollable {
self.controller_mut().cancel_fling();
}
/// See [`ScrollController::drag`].
fn drag(
&mut self,
pointer: &PointerRequests,
@@ -456,8 +283,6 @@ pub trait Scrollable {
.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()
}
@@ -482,20 +307,11 @@ pub trait Scrollable {
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,
@@ -512,11 +328,6 @@ where
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);
}
-4
View File
@@ -40,10 +40,6 @@ 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
-102
View File
@@ -8,9 +8,6 @@ use winit::{
keyboard::{Key, NamedKey},
};
/// Which way a cursor movement goes. Named here rather than taken from the text
/// stack so that the key handling below does not have to change when the stack
/// does; the mapping onto parley lives in one place, in `apply_motion`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Motion {
Left,
@@ -25,21 +22,10 @@ pub enum Motion {
pub struct TextEdit {
view: TextView,
/// `None` when the field is not focused -- which parley's `Selection` has no
/// way to say, since it always denotes some position in the text. A
/// collapsed selection is a caret; an uncollapsed one is a span.
selection: Option<Selection>,
#[cfg_attr(target_os = "android", allow(dead_code))]
history: Vec<(String, Option<Selection>)>,
double_hit: Option<usize>,
/// Where an in-flight press over this field began, while it is still
/// undecided whether the gesture is a tap (focus/show the IME) or a
/// drag (attr.rs's `Selector`/`Selectable`, Iris 2026-09-06: a swipe
/// over the composer must not summon the keyboard). `None` both before
/// any press and once the gesture has been decided either way --
/// `attr.rs` is the only reader/writer, kept `pub(crate)` rather than
/// behind an accessor since it is pure bookkeeping with no invariant
/// beyond "some press is undecided," same shape as `double_hit` above.
pub(crate) press_origin: Option<Vec2>,
pub mode: EditMode,
}
@@ -105,8 +91,6 @@ impl Widget for TextEdit {
};
let layout = self.view.buf.layout();
// parley reports selection as boxes in layout space, so bidi and
// wrapped lines come out right without this code knowing about either.
for (rect, _) in selection.geometry(layout) {
let size = vec2(rect.width() as f32, rect.height() as f32);
let top_left = vec2(rect.x0 as f32, rect.y0 as f32);
@@ -130,8 +114,6 @@ impl Widget for TextEdit {
true
}
/// I4 (RUST.md): the one override that exists so far -- everything
/// else falls back to `Widget::access_role`'s default `Unknown`.
fn access_role(&self) -> accesskit::Role {
match self.mode {
EditMode::SingleLine => accesskit::Role::TextInput,
@@ -148,11 +130,6 @@ pub struct TextEditCtx<'a> {
}
impl<'a> TextEditCtx<'a> {
/// The layout, brought up to date with the text first.
///
/// Every cursor movement and hit test goes through parley's layout, so an
/// edit that left it stale would move the caret against the previous text.
/// Shaping is skipped when nothing changed, so calling this freely is fine.
fn layout(&mut self) -> &Layout<UiColor> {
let attrs = self.text.view.attrs.clone();
let width = self.text.view.wrap_width();
@@ -161,7 +138,6 @@ impl<'a> TextEditCtx<'a> {
self.text.view.buf.layout()
}
/// Keep the selection valid after the text underneath it changed.
#[cfg_attr(target_os = "android", allow(dead_code))]
fn refresh(&mut self) {
if let Some(sel) = self.text.selection {
@@ -183,13 +159,6 @@ impl<'a> TextEditCtx<'a> {
self.text.selection = None;
}
/// [`set`](Self::set) plus a fresh set of [`SpanStyle`]s in one call --
/// what a streamed transcript row needs, since its markdown re-renders
/// to a new string *and* a new span list on every delta and the two
/// have to land together (a stale span list drawn against new text can
/// point past its end). Used by `transcript-ui`'s incremental apply
/// (RUST.md's "streaming still costs a full rebuild" fix) rather than
/// tearing the row's widget down and rebuilding it from scratch.
pub fn set_with_spans(&mut self, text: &str, spans: Vec<SpanStyle>) {
let text = self.string(text);
self.text.view.buf.set_text(text);
@@ -202,9 +171,6 @@ impl<'a> TextEditCtx<'a> {
return;
};
let layout = self.layout();
// Collapsing a span with an unshifted left/right puts the caret at the
// near end rather than moving one character from the focus, which is
// what every other editor does.
let sel = if !select && !sel.is_collapsed() {
match motion {
Motion::Left | Motion::LeftWord => {
@@ -221,8 +187,6 @@ impl<'a> TextEditCtx<'a> {
self.text.selection = Some(sel);
}
/// Replace the `len` characters before the caret. This is the IME's
/// preedit path: it re-sends the whole composition each time.
pub fn replace(&mut self, len: usize, text: &str) {
let text = self.string(text);
for _ in 0..len {
@@ -273,7 +237,6 @@ impl<'a> TextEditCtx<'a> {
self.set_caret(at + text.len());
}
/// True when there was a span to remove.
pub fn clear_span(&mut self) -> bool {
let Some(sel) = self.text.selection else {
return false;
@@ -408,27 +371,6 @@ impl<'a> TextEditCtx<'a> {
let prev_sel = self.text.selection;
let prev_hit = self.text.double_hit;
// The layout borrows `self`, so the whole decision is made in here and
// only the answer escapes.
//
// **A press that reaches here has already been hit-tested to this
// widget, so there is no "outside" to clear the selection for.**
// This used to compare `pos` against the *laid-out text's* box and
// set `selection = None` for anything beyond it -- but the laid-out
// text is smaller than the field (padding, and for an empty field a
// box of literally zero width), so tapping an **empty** composer
// granted focus, opened the keyboard, and left `selection` at
// `None` -- and `insert_str` returns early on `None`, so every
// keystroke after that was silently dropped and nothing ever
// appeared. That is RUST.md's P0 box item 2, "composed text never
// becomes visible at all": the buffer was empty the whole time, and
// Gboard's suggestion strip (its own composing state, not ours) is
// what made it look otherwise. Parley's `from_point`/
// `extend_to_point` already clamp a point outside the layout to the
// nearest cursor position, which is what a tap in a field's padding
// should do anyway. Losing focus is a separate path
// (`TextEditCtx::deselect`, called from the backend's focus
// handling), not this one.
let outcome = {
let layout = self.layout();
if drag {
@@ -436,9 +378,6 @@ impl<'a> TextEditCtx<'a> {
} else {
let hit = Selection::from_point(layout, pos.x, pos.y);
let index = hit.focus().index();
// A second click in the same place takes the word and a third
// the line; `double_hit` is what remembers that the previous
// click had already grown to a word.
Some(if recent && prev_hit == Some(index) {
(Some(Selection::line_from_point(layout, pos.x, pos.y)), None)
} else if recent && prev_sel.map(|s| s.focus().index()) == Some(index) {
@@ -572,8 +511,6 @@ fn apply_motion(
}
}
/// The ends of a byte range as cursors, so collapsing a selection can put the
/// caret at whichever end the movement asked for.
trait RangeCursors {
fn start_cursor(&self, layout: &Layout<UiColor>) -> parley::Cursor;
fn end_cursor(&self, layout: &Layout<UiColor>) -> parley::Cursor;
@@ -649,10 +586,6 @@ mod tests {
use super::*;
use iris_core::{TextAttrs, TextBuffer};
/// The editor is the one part of iris that is pure logic over a string and
/// a layout, and it was rewritten wholesale when the text stack changed --
/// so it is the one part worth testing directly. Everything else here
/// needs a GPU and a window.
fn edit(text: &str, mode: EditMode) -> (TextEdit, TextData) {
let view = TextView::new(TextBuffer::new(text), TextAttrs::default(), None);
(TextEdit::new(view, mode), TextData::default())
@@ -725,10 +658,6 @@ mod tests {
assert_eq!(t.selection.unwrap().focus().index(), 0);
}
/// The defect itself: an empty field's laid-out text is a zero-sized
/// box, so a tap anywhere in it used to land "outside" and clear the
/// selection -- leaving a focused composer that silently swallowed
/// every keystroke (RUST.md's P0 box item 2).
#[test]
fn tapping_an_empty_field_places_a_caret_so_typing_lands() {
let (mut t, mut d) = edit("", EditMode::MultiLine);
@@ -738,10 +667,6 @@ mod tests {
assert_eq!(content(&t), "hi");
}
/// The half the fix had no reason to touch: a field that *does* hold
/// text, tapped past the end of it (a multi-line composer's padding
/// below the last line) keeps a caret rather than losing the one it
/// had, and the caret lands at the nearest position -- the end.
#[test]
fn tapping_past_the_end_of_the_text_clamps_to_the_end() {
let (mut t, mut d) = edit("abc", EditMode::MultiLine);
@@ -749,8 +674,6 @@ mod tests {
assert_eq!(t.selection.unwrap().focus().index(), 3);
}
/// A drag still needs something to extend: with no previous selection
/// there is nothing to drag from, and one must not be invented.
#[test]
fn dragging_without_a_previous_selection_selects_nothing() {
let (mut t, mut d) = edit("abc", EditMode::MultiLine);
@@ -783,8 +706,6 @@ mod tests {
assert_eq!(content(&t), "");
}
/// The IME's preedit path: each keystroke resends the whole composition,
/// so `replace` has to remove exactly what it added last time.
#[test]
fn ime_preedit_replaces_its_own_previous_text() {
let (mut t, mut d) = edit("", EditMode::SingleLine);
@@ -797,10 +718,6 @@ mod tests {
assert_eq!(content(&t), "");
}
/// `android/ime.rs`'s `set_composing_text` calls `replace` and expects
/// the caret to land right after the inserted text, growing with it on
/// every re-send -- the buffer-level half of RUST.md's P0 box ("doesn't
/// enter it until I hit space, and also doesn't move cursor forward").
#[test]
fn composing_advances_the_caret_with_the_growing_text() {
let (mut t, mut d) = edit("", EditMode::SingleLine);
@@ -815,30 +732,17 @@ mod tests {
assert_eq!(t.caret(), Some(3));
}
/// The IME's `commitText` (`android_view::InputConnection::commit_text`'s
/// default body): finish a composition in place, same as a real word
/// boundary (a space) landing after Gboard's composing span.
#[test]
fn committing_composed_text_leaves_it_in_place_with_the_caret_after_it() {
let (mut t, mut d) = edit("say ", EditMode::SingleLine);
ctx(&mut t, &mut d).set_caret(4);
ctx(&mut t, &mut d).replace(0, "hi");
assert_eq!(content(&t), "say hi");
// `finish_composing_text`/`commit_text` do not themselves touch the
// buffer -- only the IME's own `compose_len` bookkeeping resets, in
// `android/ime.rs`. Confirms the buffer already holds committed
// text as plain, uncomposed content: a further `replace(0, " ")`
// (the space that ends the word) appends rather than overwriting.
ctx(&mut t, &mut d).replace(0, " ");
assert_eq!(content(&t), "say hi ");
assert_eq!(t.caret(), Some(7));
}
/// `TextEditCtx::delete_byte_range` is `deleteSurroundingText`'s entry
/// point once `android/ime.rs` has converted UTF-16 code units to
/// bytes -- exercised directly here in bytes, since the UTF-16 math
/// itself is `android/ime.rs`'s own `byte_to_utf16`/`utf16_to_byte`,
/// outside this widget-only test module.
#[test]
fn delete_byte_range_removes_exactly_that_range() {
let (mut t, mut d) = edit("hello world", EditMode::SingleLine);
@@ -847,8 +751,6 @@ mod tests {
assert_eq!(t.caret(), Some(5));
}
/// `set_cursor_byte` is `setSelection`'s entry point -- collapses to a
/// caret at the given byte offset regardless of any span that was there.
#[test]
fn set_cursor_byte_collapses_to_a_caret_there() {
let (mut t, mut d) = edit("hello world", EditMode::SingleLine);
@@ -868,8 +770,6 @@ mod tests {
assert_eq!(t.selected_text().as_deref(), Some("b"));
}
/// Collapsing a span with an unshifted arrow goes to the near end rather
/// than stepping one character from the focus.
#[test]
fn an_unshifted_arrow_collapses_a_span_to_its_edge() {
let (mut t, mut d) = edit("abcdef", EditMode::SingleLine);
@@ -882,8 +782,6 @@ mod tests {
assert_eq!(t.selection.unwrap().focus().index(), 6);
}
/// Byte offsets, not character counts: a caret placed after a multi-byte
/// character must not split it.
#[test]
fn multibyte_text_is_edited_by_byte_offset() {
let (mut t, mut d) = edit("", EditMode::SingleLine);
-41
View File
@@ -16,7 +16,6 @@ pub struct Text {
pub struct TextView {
pub attrs: MutDetect<TextAttrs>,
pub buf: MutDetect<TextBuffer>,
// cache
tex: Option<RenderedText>,
width: Option<f32>,
pub hint: Option<StrongWidget>,
@@ -27,14 +26,10 @@ impl TextView {
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 {
attrs: attrs.into(),
@@ -45,8 +40,6 @@ impl TextView {
}
}
/// region where the text should be draw
/// does not include extra height or width from weird unicode
pub fn region(&self) -> UiRegion {
self.tex()
.map(|t| t.size)
@@ -60,13 +53,6 @@ impl TextView {
} 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
@@ -78,11 +64,6 @@ impl TextView {
}
self.width = width;
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",
@@ -100,12 +81,6 @@ impl TextView {
pub fn tex(&self) -> Option<&RenderedText> {
self.tex.as_ref()
}
/// 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()
@@ -185,19 +160,6 @@ 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 {
@@ -215,9 +177,6 @@ mod tests {
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));
-25
View File
@@ -84,25 +84,6 @@ widget_trait! {
}
}
/// 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.
@@ -120,12 +101,6 @@ widget_trait! {
}
}
/// 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)),