iris is the framework alone; the app is one crate in app-rust/

Iris: "the organization of the rust rewrite is a mess right now... there
shouldn't be anything related to the app inside of iris. Iris is supposed
to be the UI framework alone." And, on the crate count: "I'm confused why
the app only code needs more than one crate though."

Nine cargo workspaces become three, and the port's project code -- which
sat in five places, four of them inside the framework -- becomes one crate,
`ai-app`, in `app-rust/`:

  client-core                -> app-rust/src/client
  iris/transcript-ui         -> app-rust/src/ui
  iris/transcript-fixture    -> app-rust/src/ui/fixture.rs + tests/ + touch/
  iris/desktop-app           -> app-rust/src/desktop + src/bin_desktop.rs
  iris/android-app           -> app-rust/src/android + android-project/
  android-shell              -> app-rust/src/shell

iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now
mentions no session, transcript, setup or server anywhere.

Only two of the old splits had a reason that survived reading. event-model
stays a crate at the repo root because server/ depends on it too, so a
crate is what makes the backend and the app agree by construction. The two
Android .so names looked like a hard constraint -- a package produces one
library artifact -- until P2 turned out to already plan merging those two
Android apps into one; both faces now come out of libai_app.so, picked
apart by features so `--no-default-features --features shell` keeps wgpu,
parley and iris out of the Compose app's APK. docs/RUST.md's "One app
crate" has the rest, including what each remaining feature is for.

DECISIONS.md and SUBAGENTS.md move into docs/ with everything else.

Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so,
build-apk.sh produces an APK that installs and launches on this checkout's
emulator (Gl ... virgl, as expected), and the phone-sized headless
screenshot renders the transcript unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 23:36:38 -04:00
1 parent 7b54aaf3c4
commit a9312e9431
113 files changed
+23177 -2948

No files matched your search

Generated
+1795 -643
View File
File diff suppressed because it is too large. Load diff
+124 -13
View File
@@ -8,35 +8,146 @@ edition.workspace = true
[dependencies] [dependencies]
iris-core = { workspace = true } iris-core = { workspace = true }
iris-macro = { workspace = true } iris-macro = { workspace = true }
cosmic-text = { workspace = true } parley = { workspace = true }
unicode-segmentation = { workspace = true } swash = { workspace = true }
winit = { workspace = true }
arboard = { workspace = true, features = ["wayland-data-control"] }
pollster = { workspace = true } pollster = { workspace = true }
wgpu = { workspace = true } wgpu = { workspace = true }
image = { workspace = true } image = { workspace = true }
accesskit = { workspace = true }
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] } tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
# For diagnostics visible through android_logger (or whatever logger the
# app crate installs) -- this crate never installs one itself. Not in the
# android-only block below any more: the lines that matter most are in
# shared widget code, which the host backend compiles too.
log = "0.4.34"
# winit everywhere except Android; android-view (below) is what stands in
# for it there. Both backends live in this crate (see `src/android/mod.rs`'s
# doc comment) but are never compiled together: winit's own Android support
# pulls in `android-activity`, which panics at compile time unless one of
# its own backend features is picked, and picking one is exactly what
# `iris-core` was kept free of (RUST.md's I0b). Confirmed by trying it
# 2026-09-05: `cargo ndk -t x86_64 -P 26 build -p iris` failed inside
# `android-activity` itself with "Either game-activity or native-activity
# must be enabled" before this split existed.
[target.'cfg(not(target_os = "android"))'.dependencies]
winit = { workspace = true }
arboard = { workspace = true, features = ["wayland-data-control"] }
# I4 (RUST.md): the desktop half of the AccessKit push, `winit`'s own
# adapter over `accesskit`. No pin needed the way android-view's rev is
# pinned -- this is an ordinary crates.io release with no local abort to
# track (that finding is Android-only, see below).
accesskit_winit = "0.34.0"
# Pinned to the exact commit RUST.md's E1 (2026-09-04) measured on this
# emulator -- real Vulkan rendering, a working `InputConnection`, and the
# accesskit-detach abort, all against this rev specifically. Advancing it
# wants re-running E1's checks, the same reason the nightly toolchain pin
# is dated rather than floating.
[target.'cfg(target_os = "android")'.dependencies]
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
# I4 (RUST.md): the Android half of the AccessKit push, over android-view's
# `AccessibilityNodeProvider`. **0.8.0 carries the same detach-abort E1
# found on 0.4.0** (the `State` enum still never returns to `Inactive`,
# and `send_completed_event` still unwraps a Java exception) -- advancing
# the version is not the fix, so pinning to a specific rev buys nothing
# here the way it does for android-view itself. `android/view.rs`'s
# `raise_if_enabled` is the mitigation, carried from E1.
accesskit_android = "0.8.0"
# Not re-exported by android-view (only `jni` and `ndk` are), and needed
# for `android/insets.rs`'s own id -> state map -- the same reason
# android-view's own `PEER_MAP` carries one.
send_wrapper = "0.6.0"
[features]
# RUST.md's I5 "Where iris's frame time goes" diagnosis: pins the
# `wgpu::Instance` to `Backends::GL` instead of `Backends::PRIMARY`, so one
# build can be measured on either backend. A compile-time feature rather
# than an env var because nothing on this machine can hand an env var to an
# already-launched Android process (there is no `am start` environment and
# no system-property reader here to add one).
#
# **Not needed to get GLES in the emulator**, whatever the history here
# says: the emulator's guest has no hardware Vulkan at all, so an ordinary
# build's runtime fallback lands on GLES by itself (docs/RUST.md, "What the
# emulator gives a GPU app"). Keeping the emulator on the same binary the
# phone runs is the point. What this feature is still for is forcing GLES
# on a machine that *does* have Vulkan -- the desktop -- which is why
# `default/render.rs` reads it too:
# ./run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui \
# --features iris/force-gles
force-gles = []
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] } tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] }
# The tabs example's widget tree. A dev-dependency cycle back to this
# package is fine -- cargo excludes dev-dependencies from the graph used
# to build the library itself, so this only matters for `--examples`.
tabs-ui = { path = "tabs-ui" }
# `tests/mask_sdf.rs` only: the grid it hands the GPU and the coverages it
# reads back. wgpu and pollster are ordinary dependencies already.
bytemuck = { workspace = true }
# Plain Instant-timed binaries, not criterion -- see benches/message_list.rs's
# header for why. `harness = false` opts out of the unstable `#[bench]`
# test-crate harness cargo would otherwise want, in favour of an ordinary
# `fn main()`.
[[bench]]
name = "message_list"
harness = false
[workspace] [workspace]
members = ["core", "macro"] members = [
"core",
"macro",
"tabs-ui",
"rig-input",
]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
# Debug info is the reason a `cargo test --workspace` here was taking half
# an hour, and it is worth the paragraph. Measured 2026-09-08: with rustc's
# default `debug = true`, linking this workspace's test binaries wrote
# **~54 GB** (one single test binary's linker wrote 16.9 GB) and left an
# **88 GB** `target/`. Eight test binaries each statically link the whole
# wgpu + naga + winit + parley graph, and at the default every one of them
# gets a full copy of that graph's DWARF written into it. On a btrfs at 83%
# full the linkers then sat in `handle_reserve_ticket` -- uninterruptible,
# waiting on space reservation -- at about 20 MB/s between them, which is
# what "the tests are slow" actually was. Not CPU: the machine was 87% idle
# throughout.
#
# `line-tables-only` keeps what is actually read from a backtrace -- the
# file and line of every frame, which is what a panicking test prints and
# what gdb needs to name the frames of a segfault. What it gives up is
# inspecting variables in a debugger; when that is wanted, ask for it on
# the command line for that one run rather than paying for it on every
# build:
#
# RUSTFLAGS="-C debuginfo=2" cargo test -p iris --test whatever
[profile.dev]
debug = "line-tables-only"
# The tests are what this is really for; `cargo test` uses `dev` for
# dependencies and `test` for the test targets themselves, so setting only
# `dev` leaves the eight big binaries at the default.
[profile.test]
debug = "line-tables-only"
[workspace.dependencies] [workspace.dependencies]
pollster = "0.4.0" pollster = "1.0.1"
winit = "0.30.12" winit = "0.30.13"
wgpu = "28.0.0" wgpu = "30.0.1"
bytemuck = "1.23.1" bytemuck = "1.25.2"
image = "0.25.6" image = "0.25.10"
cosmic-text = "0.16.0" parley = "0.11.1"
unicode-segmentation = "1.12.0" swash = "0.2.10"
fxhash = "0.2.1" fxhash = "0.2.1"
arboard = "3.6.1" arboard = "3.6.1"
accesskit = "0.25.0"
iris-core = { path = "core" } iris-core = { path = "core" }
iris-macro = { path = "macro" } iris-macro = { path = "macro" }
tokio = "1.49.0" tokio = "1.53.1"
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""AOSP's fling spline, transcribed independently of the Rust port.
This exists so the numbers in `sense.rs`'s `the_spline_matches_aosps_own_table`
and `a_flick_decelerates_the_way_aosp_says_it_does` are not the Rust code
grading its own homework. Every test iris's fling had before 2026-09-07
compared the curve with itself -- monotonic, signed, integrates to the closed
form -- and all of them passed while `distance_fraction(t)` was returning
exactly `t` (see `android_fling_spline`'s doc comment). Numbers checked into a
test have to come from somewhere else, and this is the somewhere else.
Transcribed by hand from, and only from:
* frameworks/base `core/java/android/widget/OverScroller.java`,
`SplineOverScroller`'s static initialiser, `getSplineDeceleration`,
`getSplineFlingDistance`, `getSplineFlingDuration` and `update`.
* androidx.compose.animation:animation:1.12.0 `SplineBasedDecay.kt`
(`computeSplineInfo`, `AndroidFlingSpline.flingPosition`) and
`FlingCalculator.kt` (`computeDeceleration`, `flingDistance`,
`flingDuration`, `FlingInfo.position`/`velocity`). The two agree line for
line, which is why iris ports one curve rather than two.
Run it with no arguments; it prints the table entries and the (velocity,
density, t) points the Rust tests assert on.
"""
NB_SAMPLES = 100
INFLEXION = 0.35
START_TENSION = 0.5
END_TENSION = 1.0
P1 = START_TENSION * INFLEXION
P2 = 1.0 - END_TENSION * (1.0 - INFLEXION)
# ViewConfiguration.getScrollFriction(), and SplineOverScroller's own
# "look and feel tuning" constant -- a different number in a different place
# of the same formula, which is the pair iris got the wrong way round once.
SCROLL_FRICTION = 0.015
TUNING = 0.84
GRAVITY_EARTH = 9.80665
INCHES_PER_METER = 39.37
import math
DECELERATION_RATE = math.log(0.78) / math.log(0.9)
def spline_positions():
"""SPLINE_POSITION: distance fraction at each of 101 even time steps."""
position = [0.0] * (NB_SAMPLES + 1)
x_min = 0.0
for i in range(NB_SAMPLES):
alpha = i / NB_SAMPLES
x_max = 1.0
while True:
x = x_min + (x_max - x_min) / 2.0
coef = 3.0 * x * (1.0 - x)
# Solved on the P1/P2 curve...
tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x
if abs(tx - alpha) < 1e-5:
break
if tx > alpha:
x_max = x
else:
x_min = x
# ...and sampled on the tension curve.
position[i] = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x
position[NB_SAMPLES] = 1.0
return position
POSITION = spline_positions()
def fling_sample(t):
"""(distance fraction, velocity fraction) at time fraction `t`."""
t = min(max(t, 0.0), 1.0)
index = int(t * NB_SAMPLES)
if index >= NB_SAMPLES:
return 1.0, 0.0
t_inf = index / NB_SAMPLES
t_sup = (index + 1) / NB_SAMPLES
velocity_coef = (POSITION[index + 1] - POSITION[index]) / (t_sup - t_inf)
return POSITION[index] + (t - t_inf) * velocity_coef, velocity_coef
def physical_coefficient(density):
return GRAVITY_EARTH * INCHES_PER_METER * density * 160.0 * TUNING
def deceleration(velocity, density):
return math.log(
INFLEXION * abs(velocity) / (SCROLL_FRICTION * physical_coefficient(density))
)
def fling_distance(velocity, density):
l = deceleration(velocity, density)
return (
SCROLL_FRICTION
* physical_coefficient(density)
* math.exp(DECELERATION_RATE / (DECELERATION_RATE - 1.0) * l)
)
def fling_duration_s(velocity, density):
l = deceleration(velocity, density)
return math.exp(l / (DECELERATION_RATE - 1.0))
def position_at(velocity, density, t_seconds):
d = fling_duration_s(velocity, density)
return fling_distance(velocity, density) * fling_sample(t_seconds / d)[0]
def velocity_at(velocity, density, t_seconds):
d = fling_duration_s(velocity, density)
return fling_sample(t_seconds / d)[1] * fling_distance(velocity, density) / d
if __name__ == "__main__":
print("SPLINE_POSITION at a few indices (index: value)")
for i in (0, 1, 10, 25, 50, 75, 99, 100):
print(f" {i:3}: {POSITION[i]:.6f}")
print()
print("distance/velocity fraction at time fractions")
for t in (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0):
d, v = fling_sample(t)
print(f" t={t:<5} distance={d:.6f} velocity={v:.6f}")
print()
# 2.55 is Iris's Pixel 9 Pro XL (docs/bench/iris-phone-v2-2026-09-06.md);
# 2.75 is this checkout's emulator.
for density in (2.55, 2.75):
# 15250 is `transcript-fixture/touch/flick-120hz.touch`'s own
# release velocity (velocity_reference.py), so `phone_screen.rs`
# can bound the fling it produces from *here* rather than from the
# `FlingCalculator` under test (docs/REVIEW-2026-09-07.md's T1).
for velocity in (5000.0, 11064.0, 15250.0):
dur = fling_duration_s(velocity, density)
print(
f"density={density} v={velocity}: "
f"distance={fling_distance(velocity, density):.3f}px "
f"duration={dur:.4f}s"
)
# Deliberately not round fractions. The velocity coefficient is
# piecewise *constant* across each of the 100 samples, so it
# steps at t = k/100 and a test asserting on 0.75 is asserting
# on which side of a discontinuity the last float landed --
# which is genuinely different between Python and Rust and says
# nothing about the curve.
for frac in (0.125, 0.335, 0.505, 0.755):
t = frac * dur
print(
f" t={frac:>4} of duration ({t:.4f}s): "
f"pos={position_at(velocity, density, t):.3f}px "
f"vel={velocity_at(velocity, density, t):.3f}px/s"
)
+493
View File
@@ -0,0 +1,493 @@
//! On-demand benchmarks for iris's message-list scenario -- IRIS_TODO.md's
//! "Benchmarks" item, and RUST.md's I3. Never run by `cargo test`; run
//! explicitly with `cargo bench --bench message_list --release` or
//! `./run-bench.sh`.
//!
//! **Why a plain `Instant`-timed binary, not criterion.** Every scenario
//! here is really "how many `Widget::draw` calls and primitive rewrites did
//! this frame cost," which `UiRenderState::take_counters` already answers
//! exactly (see `iris/src/layout_tests.rs`, which this file's harness
//! mirrors). A short loop that times itself and prints the counters
//! alongside the wall time says everything criterion's warm-up/sampling/
//! outlier-removal machinery would add on top, for scenarios that are
//! fundamentally about a *count*, not a noisy microbenchmark distribution
//! -- and it avoids a new dependency this crate does not otherwise need.
//! Per the code rules, the plain option is also the one shorter to explain.
//!
//! **The list under test is `iris::widget::LazySpan` (RUST.md's I3), not a
//! `ScrollArea` over a `Span` of pre-built rows.** Earlier versions of this
//! file built their own giant `Span` and wrapped it in `ScrollArea`, which
//! meant (a)/(b)/(c) below were measuring "move one big child," never the
//! virtualised widget the app's transcript screen actually needs. `LazySpan`
//! still needs every row's *widget* built up front by the caller (its
//! module doc explains why: it only ever sees `&dyn Widget` through
//! `Painter`, so it cannot construct a row lazily on its own) -- what
//! virtualisation buys is that only the rows currently on screen are ever
//! *drawn*, which is what the draw/rewrite/move counters below are
//! measuring, not construction time.
//!
//! Scenarios (LAYOUT.md's O(1) move chain, lazy_span.rs's module doc, and
//! IRIS_TODO.md's "Benchmarks" wording):
//!
//! - (a) first-frame cost of a message list of N wrapped-text rows, some
//! with an image, for N = 100 / 1,000 / 10,000. With a virtualised list
//! this is expected to stop scaling with N once N exceeds a screenful --
//! the draw/rewrite counters below are the number that used to grow 10x
//! per 10x N and should not any more.
//! - (b) per-frame cost of scrolling that list -- must be O(1) moves, not
//! re-layout.
//! - (c) the input-box case: growing a fixed-height field at the bottom of
//! the screen must move the message list above it, not re-lay its rows.
//! Reports frame time *and* the draw/rewrite/move counters LAYOUT.md
//! section 8 defines.
//! - (d) insert-above-anchor: paging older history onto the front of an
//! already-scrolled list. `LazySpan::push_front` is an O(1) index update
//! (lazy_span.rs's module doc); this measures that none of the rows already
//! on screen are touched by it.
//! - (e) expand-a-row-holding-its-edge: growing one row's height with a
//! tap recorded near one of its edges (lazy_span.rs's `note_tap`) must move
//! only the rows on the far side of it, never redraw the ones already
//! correctly placed.
//!
//! - (g) redraw-one-big-text: a single text widget of N glyphs redrawn in
//! place, which is what a tool card rebuilt on a tap costs. Every one of
//! its primitives is freed and rewritten, and so renumbered in the
//! layer's draw order -- the pass that used to be O(N^2) there
//! (`UiRenderState::apply_free`, fixed 2026-09-08). The number to watch
//! is per-glyph: it must stay flat as N grows, not grow with it.
//!
//! (f), many images with zero steady-state bind-group creation, needs a
//! real `wgpu` device and lives in `iris/examples/bench_images.rs` instead,
//! driven through `run-headless.sh` -- see that file's header.
//!
//! `UiRenderState`/`Widgets` touch no GPU or window (as `layout_tests.rs`
//! notes), so everything here runs as an ordinary `--release` binary with
//! no compositor. Numbers are recorded in RUST.md's I3 box, not here --
//! this file is the rig, not the result.
use iris::prelude::*;
use std::time::Instant;
/// The minimal `UiRsc` a benchmark needs -- identical in shape to
/// `layout_tests.rs`'s `TestRsc`.
struct BenchRsc {
ui: UiData,
}
impl UiRsc for BenchRsc {
fn ui(&self) -> &UiData {
&self.ui
}
fn ui_mut(&mut self) -> &mut UiData {
&mut self.ui
}
}
/// Long enough to force real wrapping at a phone-plausible column width, and
/// varied enough (no two rows byte-identical) that nothing can special-case
/// on repeated content.
const BODY: &str = "The quick brown fox jumps over the lazy dog. Iris lays \
out wrapped text by shaping once per width and caching the result, so a \
row that is offered the same width twice does not reshape. This sentence \
exists only to give a row enough text to wrap across several lines at a \
typical phone column width.";
/// One message row: a wrapped `Text`, and every `image_every`th row also an
/// `Image` beneath it -- a small in-memory RGBA square rather than a file,
/// so N=10,000 rows costs no disk I/O.
fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget {
let mut text = Text::new(format!("Message {i}: {BODY}"));
text.wrap = true;
let text = rsc.ui.widgets.add_strong(text).any();
if image_every > 0 && i.is_multiple_of(image_every) {
let img = image::DynamicImage::new_rgba8(64, 64);
let image_widget = image::<BenchRsc>(img)(rsc);
let image_widget = rsc.ui.widgets.add_strong(image_widget).any();
let mut row = Span::empty(Dir::DOWN);
row.push(text);
row.push(image_widget);
rsc.ui.widgets.add_strong(row).any()
} else {
text
}
}
/// A virtualised `LazySpan` of `n` message rows, one in `image_every` of them
/// carrying an image (0 disables images entirely). Returns the list widget
/// (weak, so the caller can drive it) and the erased root to render.
fn build_message_list(
rsc: &mut BenchRsc,
n: usize,
image_every: usize,
) -> (WeakWidget<LazySpan>, StrongWidget) {
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
for i in 0..n {
let row = build_row(rsc, i, image_every);
list.push_back(LazyItem::new(i as u64, row));
}
let list = rsc.ui.widgets.add_strong(list);
// Driven through the span's own `ScrollController`, like every other
// scroll area in iris: what this measures has to be the path the app
// actually takes.
(list.weak(), list.any())
}
fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) {
println!(
"{label}: {:.2}ms draws={draws} rewrites={rewrites} moves={moves}",
elapsed.as_secs_f64() * 1000.0
);
}
/// (a) First-frame cost of a message list of N rows.
fn bench_first_frame(n: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (_list, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
let start = Instant::now();
render.update(&root, &mut rsc);
let elapsed = start.elapsed();
let (draws, rewrites, moves, _shapes) = render.take_counters();
report(
&format!("(a) first frame, N={n}"),
elapsed,
draws,
rewrites,
moves,
);
}
/// (b) Per-frame cost of scrolling an already-laid-out list of N rows.
/// Warms up (one no-op tick, matching `ScrollArea`'s own need for it before an
/// ordinary Rust `layout_tests.rs` scrolling test becomes a same-size move
/// rather than a resize), then times a run of individual scroll ticks.
fn bench_scroll(n: usize, ticks: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (scroll, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
render.take_counters();
let mut total = std::time::Duration::ZERO;
let mut total_draws = 0u64;
let mut total_rewrites = 0u64;
let mut total_moves = 0u64;
for _ in 0..ticks {
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-8.0);
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
let (draws, rewrites, moves, _shapes) = render.take_counters();
total_draws += draws;
total_rewrites += rewrites;
total_moves += moves;
}
report(
&format!("(b) scroll, N={n}, {ticks} ticks (totals; expect draws/moves independent of N)"),
total,
total_draws,
total_rewrites,
total_moves,
);
println!(
" per-tick average: {:.4}ms",
total.as_secs_f64() * 1000.0 / ticks as f64
);
}
/// (c) The input-box case: a fixed-height field at the bottom of the screen
/// growing by a line at a time, with a message list of N rows filling the
/// rest of the screen above it. Growing the input shrinks the *offered*
/// height of the list container (a single widget, from the outer `Span`'s
/// point of view) without changing the width it offers its content -- so
/// the rows underneath, which only care about width, must not redraw; the
/// list's own re-registration of where its content sits is the one O(1)
/// move this is checking for.
fn bench_input_grows(n: usize, lines: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (scroll, list_root) = build_message_list(&mut rsc, n, 20);
let list_area = rsc.ui.widgets.add_strong(Sized {
inner: list_root,
x: None,
y: Some(rest(1.0)),
});
let line_height = 24.0;
let input_rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let input_area = rsc.ui.widgets.add_strong(Sized {
inner: input_rect.any(),
x: None,
y: Some(abs(line_height)),
});
let input_area_weak = input_area.weak();
let mut root_span = Span::empty(Dir::DOWN);
root_span.push(list_area.any());
root_span.push(input_area.any());
let root = rsc.ui.widgets.add_strong(root_span).any();
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
render.take_counters();
let mut total = std::time::Duration::ZERO;
let mut total_draws = 0u64;
let mut total_rewrites = 0u64;
let mut total_moves = 0u64;
for line in 1..=lines {
rsc.ui.widgets.get_mut(&input_area_weak).unwrap().y =
Some(abs(line_height * (line + 1) as f32));
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
let (draws, rewrites, moves, _shapes) = render.take_counters();
total_draws += draws;
total_rewrites += rewrites;
total_moves += moves;
}
report(
&format!(
"(c) input grows by {lines} lines above N={n} rows (totals; \
draws/rewrites must not scale with N)"
),
total,
total_draws,
total_rewrites,
total_moves,
);
println!(
" per-line average: {:.4}ms",
total.as_secs_f64() * 1000.0 / lines as f64
);
}
/// (d) Insert-above-anchor: the list is scrolled to its very first loaded
/// row (`jump_to_start`, an O(1) re-anchor) rather than left at the default
/// bottom, so a row prepended above it is genuinely "inserted above the
/// anchor" rather than merely far off-screen at the far end. Each
/// `push_front` is O(1) (lazy_span.rs's module doc: the anchor's slot is an
/// index, bumped by one) and, since the prepended rows never enter the
/// viewport, none of them should cost a draw either.
fn bench_insert_above_anchor(n: usize, inserts: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let (list, root) = build_message_list(&mut rsc, n, 20);
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&list).unwrap().jump_to_start();
render.update(&root, &mut rsc);
render.take_counters();
let mut total = std::time::Duration::ZERO;
let mut total_draws = 0u64;
let mut total_rewrites = 0u64;
let mut total_moves = 0u64;
for i in 0..inserts {
// Older-history rows: distinct keys below every existing one, so a
// real caller's paging code (prepending an older page) is exactly
// what this loop does.
let row = build_row(&mut rsc, usize::MAX - i, 20);
rsc.ui
.widgets
.get_mut(&list)
.unwrap()
.push_front(LazyItem::new(i as u64, row));
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
let (draws, rewrites, moves, _shapes) = render.take_counters();
total_draws += draws;
total_rewrites += rewrites;
total_moves += moves;
}
report(
&format!(
"(d) insert-above-anchor, N={n}, {inserts} pushes (totals; \
must not scale with N)"
),
total,
total_draws,
total_rewrites,
total_moves,
);
println!(
" per-push average: {:.4}ms",
total.as_secs_f64() * 1000.0 / inserts as f64
);
}
/// (e) Expand-a-row-holding-its-edge: one row (fixed-height, so its size is
/// directly controllable) is grown a little at a time, each time preceded
/// by `note_tap` aimed at its own top edge -- the exact mechanism lazy_span.rs's
/// module doc describes and its unit tests check for correctness. This
/// measures its *cost*: only the rows on the far side of the grown one
/// (below it, since the top edge is held) should ever move, and nothing
/// should be redrawn purely because the list overall got taller.
fn bench_expand_holds_edge(n: usize, growths: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
// Near the end (not the very last row) so it is already on screen
// under the list's default bottom-anchored placement, for every N --
// no scrolling needed to bring it into view before measuring.
let growable_index = n.saturating_sub(3);
let mut growable = None;
for i in 0..n {
if i == growable_index {
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let sized = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(abs(40.0)),
});
growable = Some(sized.weak());
list.push_back(LazyItem::new(i as u64, sized.any()));
} else {
let row = build_row(&mut rsc, i, 20);
list.push_back(LazyItem::new(i as u64, row));
}
}
let list = rsc.ui.widgets.add_strong(list);
let list_weak = list.weak();
let root = list.any();
let growable = growable.unwrap();
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
render.take_counters();
let mut total = std::time::Duration::ZERO;
let mut total_draws = 0u64;
let mut total_rewrites = 0u64;
let mut total_moves = 0u64;
let mut height = 40.0f32;
let key = growable_index as u64;
for _ in 0..growths {
height += 10.0;
if let Some((top, _bottom)) = rsc.ui.widgets.get(&list_weak).unwrap().extent(key) {
rsc.ui
.widgets
.get_mut(&list_weak)
.unwrap()
.note_tap(top + 1.0);
}
rsc.ui.widgets.get_mut(&growable).unwrap().y = Some(abs(height));
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
let (draws, rewrites, moves, _shapes) = render.take_counters();
total_draws += draws;
total_rewrites += rewrites;
total_moves += moves;
}
report(
&format!(
"(e) expand-hold, N={n}, {growths} growths (totals; \
must not scale with N)"
),
total,
total_draws,
total_rewrites,
total_moves,
);
println!(
" per-growth average: {:.4}ms",
total.as_secs_f64() * 1000.0 / growths as f64
);
}
/// (g) One text widget of `chars` characters, redrawn in place `redraws`
/// times -- an open tool card whose content is rebuilt, or any widget
/// holding a lot of text that a tap changes.
///
/// A redraw frees every primitive the widget owned and writes fresh ones,
/// so every glyph is renumbered in its layer's draw order. Finding the
/// handle to renumber used to be a scan of everything the same widget
/// drew, which made one redraw quadratic in its own glyph count: 1.37s for
/// 51,200 glyphs on this machine, against 20ms to shape and rasterise the
/// same text. Print per-glyph rather than per-redraw, since flat is the
/// pass condition and a total says nothing without dividing it.
fn bench_redraw_big_text(chars: usize, redraws: usize) {
let mut rsc = BenchRsc {
ui: UiData::default(),
};
// One character per glyph, and varied so nothing can collapse the
// string into a repeat.
let content: String = (0..chars)
.map(|i| char::from(b'a' + (i % 26) as u8))
.collect();
let mut text = Text::new(content);
text.wrap = true;
let text = rsc.ui.widgets.add_strong(text);
let handle = text.weak();
let root = text.any();
let mut render = UiRenderState::new();
render.resize((1080.0, 2000.0));
render.update(&root, &mut rsc);
render.take_counters();
let mut total = std::time::Duration::ZERO;
for _ in 0..redraws {
// Asking for the widget mutably is what marks it for redraw --
// the same path a caller changing its content takes.
rsc.ui.widgets.get_mut(&handle).unwrap();
let start = Instant::now();
render.update(&root, &mut rsc);
total += start.elapsed();
}
let (draws, rewrites, moves, _shapes) = render.take_counters();
report(
&format!("(g) redraw one {chars}-glyph text, {redraws}x (totals)"),
total,
draws,
rewrites,
moves,
);
println!(
" per redraw: {:.3}ms, per glyph: {:.4}us",
total.as_secs_f64() * 1000.0 / redraws as f64,
total.as_secs_f64() * 1_000_000.0 / (redraws * chars) as f64,
);
}
fn main() {
println!("iris message-list benchmark -- release build, this machine's CPU");
for &n in &[100usize, 1_000, 10_000] {
bench_first_frame(n);
}
for &n in &[100usize, 1_000, 10_000] {
bench_scroll(n, 200);
}
for &n in &[100usize, 1_000, 10_000] {
bench_input_grows(n, 40);
}
for &n in &[100usize, 1_000, 10_000] {
bench_insert_above_anchor(n, 200);
}
for &n in &[100usize, 1_000, 10_000] {
bench_expand_holds_edge(n, 40);
}
for &chars in &[1_000usize, 10_000, 50_000] {
bench_redraw_big_text(chars, 10);
}
}
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Turns `iris::input` debug lines -- from a phone's diagnostics report, or
from a report the layer-1 harness produced with tracing on
(`iris::diagnostics::set_trace(true)`) -- back into a `TouchScript` file
`iris::harness::Harness::replay` can play back at layer 1.
Why this exists: `docs/RUST.md`'s "Three test layers" box says the cheapest
layer that can answer a question wins, and a gesture that misbehaves on
Iris's phone is otherwise only describable in words. `iris::sense::
log_input_event`'s one line per platform event (Android's on_touch_event
once per `MotionEvent`, with historical samples inline; winit's once per
pointer `WindowEvent`; the harness's `touch`, once per script line) already
carries everything a `.touch` file's `t_ms action x y` needs -- this just
reads it back out and reconstructs the samples in order, expanding each
event's inline historical samples into their own `move` lines first (they
are always intermediate positions of a move, and Android documents them as
oldest first, which is also the order they appear in the line).
Usage:
report_to_touch.py < report.txt > replay.touch
report_to_touch.py report.txt > replay.touch
Only lines containing "iris input: action=..." are read; everything else in
the report (insets, frame timings, drag-release summaries) is ignored, so
this can be pointed at Copy report's whole clipboard text directly.
"""
import re
import sys
# The message half of `sense::log_input_event`'s format string, prefix-
# agnostic: a real report line also carries the ring's own
# `HH:MM:SS.mmm LEVEL target:` header (`LogLine::format`) or, forwarded
# through `ai_server::client_log`, a `[<source> <clock> #<seq>]` tag ahead
# of that -- neither of which this needs to understand, since `search`
# (not `match`) finds the marker wherever it starts.
LINE_RE = re.compile(
r"iris input: action=(?P<action>\w+) x=(?P<x>-?[0-9.]+) y=(?P<y>-?[0-9.]+) "
r"t=(?P<t>[0-9]+)ms history=(?P<hist>[0-9]+)(?P<rest>.*)$"
)
# One historical sample inside `rest`: `t:x,y`, space-separated, oldest first
# -- see `log_input_event`'s own doc for why order matters.
HIST_RE = re.compile(r"(?P<t>[0-9]+):(?P<x>-?[0-9.]+),(?P<y>-?[0-9.]+)")
def _fmt(value: float) -> str:
"""The number as `TouchScript::parse`'s own `f32::parse` would round-trip
it -- an integer without a trailing `.0` where the source was one
(every coordinate here is a physical pixel), `{:g}` otherwise so a
fractional value from a real device is not silently truncated."""
if value == int(value):
return str(int(value))
return f"{value:g}"
def convert(lines):
"""Every `iris::input` line, oldest first, expanded to one `(t_ms,
action, x, y)` tuple per touch sample -- a historical sample is always
an intermediate `move`, and the event's own sample keeps its real
action (`down`/`move`/`up`/`cancel`)."""
rows = []
for line in lines:
m = LINE_RE.search(line)
if not m:
continue
hist_count = int(m.group("hist"))
hist_matches = list(HIST_RE.finditer(m.group("rest")))
if len(hist_matches) != hist_count:
print(
f"report_to_touch: {line.strip()!r} says history={hist_count} but "
f"holds {len(hist_matches)} samples -- skipped",
file=sys.stderr,
)
continue
for hm in hist_matches:
rows.append(
(int(hm.group("t")), "move", float(hm.group("x")), float(hm.group("y")))
)
rows.append(
(int(m.group("t")), m.group("action"), float(m.group("x")), float(m.group("y")))
)
return rows
def main():
if len(sys.argv) > 2:
print("usage: report_to_touch.py [report.txt] < report.txt", file=sys.stderr)
return 2
text = open(sys.argv[1]) if len(sys.argv) == 2 else sys.stdin
for t_ms, action, x, y in convert(text):
print(f"{t_ms} {action} {_fmt(x)} {_fmt(y)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""Compose's touch velocity tracker, transcribed independently of the Rust port.
Same reason `fling_spline_reference.py` exists: the numbers checked into
`sense.rs`'s velocity tests must not be numbers the Rust produced. The old
estimator -- total motion over the sample span, an average -- passed every test
it had, because every one of those tests asserted the average's own definition
back at it. An average cannot tell an accelerating flick from a steady drag, and
that is exactly what Iris reported from the phone on 2026-09-07: "flinging now
actually works but is slower than Compose's immediately after releasing the
flick".
Transcribed by hand from, and only from, the `-sources.jar` of
**androidx.compose.ui:ui-android:1.12.0** and
**androidx.compose.foundation:foundation-android:1.12.0**
(dl.google.com/dl/android/maven2), read 2026-09-07:
* `androidx/compose/ui/input/pointer/util/VelocityTracker.kt` --
`VelocityTracker1D.calculateVelocity`, `polyFitLeastSquares`,
`calculateImpulseVelocity`, `kineticEnergyToVelocity`, and the constants
`HistorySize = 20`, `HorizonMilliseconds = 100`,
`AssumePointerMoveStoppedMilliseconds = 40`.
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.kt` --
`Lsq2VelocityTracker`, which is what the 2D `VelocityTracker` delegates to.
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.android.kt`
-- the `AndroidComposeUiFlags.isFrameworkVelocityTrackerEnabled` fork.
* `androidx/compose/ui/AndroidComposeUiFlags.android.kt` -- that flag's
default, which is `false`.
* `androidx/compose/foundation/gestures/Draggable.kt` -- `sendDragStart` /
`sendDragEvent` / `sendDragStopped`, i.e. *which* samples a touch drag
feeds the tracker and where the maximum-velocity clamp is applied.
* `androidx/compose/foundation/gestures/DifferentialVelocityTracker.kt` and
`NonTouchScrollingLogic.kt` -- the Impulse strategy's only caller.
* `androidx/compose/foundation/gestures/Scrollable.kt` --
`DefaultFlingBehavior.performFling`, for the minimum-velocity question.
**Which strategy a touch fling actually uses, since this was the surprise.**
`Strategy.Impulse` is *not* it. `scrollable`/`draggable` release through
`DragGestureNode.sendDragStopped`, which calls the 2D `VelocityTracker`; on
Android that is `Lsq2VelocityTracker` (the framework-tracker flag defaults to
false), which is two `VelocityTracker1D(strategy = Lsq2)` -- a degree-2
least-squares fit over **absolute positions**, whose velocity is the fitted
polynomial's derivative at the newest sample. Impulse is reached only through
`DifferentialVelocityTracker`, whose sole caller is `NonTouchScrollingLogic`:
mouse wheel and trackpad, never a finger. So this script transcribes Lsq2 and
iris ports Lsq2. `calculate_impulse_velocity` is here anyway, unused by the
printed points, because ruling it out by reading is cheaper than ruling it out
again next time somebody remembers "Compose uses impulse".
**Which samples a touch drag feeds it.** `sendDragStart` adds the DOWN change;
every subsequent MOVE, historical samples included, is added by `sendDragEvent`.
The **UP position is never added**: `Lsq2VelocityTracker.addPointerInputChange`
wraps its two `addPosition` calls in `if (!event.changedToUpIgnoreConsumed())`,
and all the UP branch does is reset the tracker when more than 40ms have passed
since the last MOVE (b/238654963). So a finger that stops before lifting reads
as a stop, not as a decelerating tail. Positions are the raw event positions,
so the touch slop is inside the motion the tracker sees even though the list
never scrolled by it.
Two of Compose's samples iris does *not* reproduce, both noted rather than
copied: pre-slop MOVEs (iris's `DragArbiter` is `Undecided` then too, so it
feeds none either -- these agree), and the single MOVE that *crosses* the slop,
which Compose drops because `sendDragStart` adds only the DOWN. iris feeds that
one, since it is a real measured position and dropping it would be copying a
quirk of where Compose happens to split its state machine.
**The clamps.** Maximum: `sendDragStopped` passes
`LocalViewConfiguration.maximumFlingVelocity`, which on Android is
`ViewConfiguration.getScaledMaximumFlingVelocity()` -- 8000 dp/s. Minimum:
there is **none** on this path. `ViewConfiguration.minimumFlingVelocity`
exists in Compose's `ViewConfiguration` interface but its only use in either
artifact is `NestedScrollInteropConnection`, for View interop.
`DefaultFlingBehavior.performFling` guards with `abs(initialVelocity) > 1f`
and says why in its own comment: "we need it since spline curve gives us
NaNs". 1 px/s, not 50 dp/s.
Run it with no arguments; it prints the sample sets and the velocities the
Rust tests assert on.
"""
import math
HISTORY_SIZE = 20
HORIZON_MILLISECONDS = 100.0
ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS = 40.0
MIN_SAMPLE_SIZE_LSQ2 = 3
# ViewConfiguration.getScaledMaximumFlingVelocity(), in dp/s.
MAXIMUM_FLING_VELOCITY_DP_S = 8000.0
# DefaultFlingBehavior.performFling's own threshold, in the units of the
# positions fed to the tracker -- pixels per second here.
FLING_MINIMUM_PX_S = 1.0
def poly_fit_least_squares(x, y, sample_count, degree):
"""`polyFitLeastSquares`: Gram-Schmidt QR, coefficients low order first."""
if degree < 1:
raise ValueError("The degree must be at positive integer")
if sample_count == 0:
raise ValueError("At least one point must be provided")
truncated_degree = sample_count - 1 if degree >= sample_count else degree
m = sample_count
n = truncated_degree + 1
# a[i][h] = x[h]**i, pre-multiplied by the (always 1.0) weight.
a = [[0.0] * m for _ in range(n)]
for h in range(m):
a[0][h] = 1.0
for i in range(1, n):
a[i][h] = a[i - 1][h] * x[h]
q = [[0.0] * m for _ in range(n)]
r = [[0.0] * n for _ in range(n)]
for j in range(n):
w = q[j]
w[:] = a[j][:m]
for i in range(j):
z = q[i]
dot = sum(w[h] * z[h] for h in range(m))
for h in range(m):
w[h] -= dot * z[h]
norm = math.sqrt(sum(v * v for v in w))
inverse_norm = 1.0 / max(norm, 1e-6)
for h in range(m):
w[h] *= inverse_norm
for i in range(n):
r[j][i] = 0.0 if i < j else sum(w[h] * a[i][h] for h in range(m))
coefficients = [0.0] * n
for i in range(n - 1, -1, -1):
c = sum(q[i][h] * y[h] for h in range(m))
for j in range(n - 1, i, -1):
c -= r[i][j] * coefficients[j]
coefficients[i] = c / r[i][i]
return coefficients
def kinetic_energy_to_velocity(kinetic_energy):
sign = 0.0 if kinetic_energy == 0.0 else math.copysign(1.0, kinetic_energy)
return sign * math.sqrt(2 * abs(kinetic_energy))
def calculate_impulse_velocity(data_points, time, sample_count, is_data_differential):
"""`calculateImpulseVelocity` -- not on the touch path; see the module doc."""
work = 0.0
start = sample_count - 1
next_time = time[start]
for i in range(start, 0, -1):
current_time = next_time
next_time = time[i - 1]
if current_time == next_time:
continue
if is_data_differential:
delta = -data_points[i - 1]
else:
delta = data_points[i] - data_points[i - 1]
v_curr = delta / (current_time - next_time)
v_prev = kinetic_energy_to_velocity(work)
work += (v_curr - v_prev) * abs(v_curr)
if i == start:
work = work * 0.5
return kinetic_energy_to_velocity(work)
def calculate_velocity(samples):
"""`VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`.
`samples` is `(time_millis, position)` oldest first, at most the last
`HISTORY_SIZE` of which the circular buffer would still be holding.
Returns units per second.
"""
held = samples[-HISTORY_SIZE:]
if not held:
return 0.0
data_points = []
time = []
newest_time, _ = held[-1]
previous_time = newest_time
for sample_time, sample_position in reversed(held):
age = float(newest_time - sample_time)
delta = abs(float(sample_time - previous_time))
# Lsq2 walks back sample to sample; only the non-differential
# Impulse branch compares every sample against the newest one.
previous_time = sample_time
if age > HORIZON_MILLISECONDS or delta > ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS:
break
data_points.append(sample_position)
time.append(-age)
if len(data_points) == HISTORY_SIZE:
break
if len(data_points) < MIN_SAMPLE_SIZE_LSQ2:
return 0.0
try:
coefficients = poly_fit_least_squares(time, data_points, len(data_points), 2)
except ValueError:
return 0.0
# The 2nd coefficient is the fitted polynomial's derivative at x = 0,
# which is the newest sample's timestamp. units/ms -> units/s.
return coefficients[1] * 1000.0
def clamped(velocity, maximum):
"""`VelocityTracker1D.calculateVelocity(maximumVelocity)`."""
if velocity == 0.0 or math.isnan(velocity):
return 0.0
return min(velocity, maximum) if velocity > 0 else max(velocity, -maximum)
def average(samples):
"""The estimator being replaced: total motion over the span."""
if len(samples) < 2:
return 0.0
span = (samples[-1][0] - samples[0][0]) / 1000.0
if span <= 0.0:
return 0.0
return (samples[-1][1] - samples[0][1]) / span
# --- The three recorded sample sets the Rust tests assert on. ----------------
# 1. `transcript-fixture/touch/flick-120hz.touch`, as `DragGesture` feeds it:
# the DOWN position, then one position per MOVE. The UP at t=20 adds no
# sample (see the module doc), which is why the finger sitting still for its
# last 4ms does not drag the estimate down. y only; the flick is vertical.
FLICK_120HZ = [(0, 1000.0), (4, 1040.0), (8, 1086.0), (12, 1138.0), (16, 1196.0)]
# 2. A steady drag: 5px every 10ms for 100ms. A constant-velocity fit and an
# average must agree here -- this is the case that cannot tell the two
# estimators apart, which is why it is not the only one.
STEADY_DRAG = [(i * 10, float(i * 5)) for i in range(11)]
# 3. A flick that accelerates into the release: 10ms apart, deltas doubling.
# This is the case the average gets wrong, and the negative control for
# the port -- reverting to the average must fail this test and only this
# kind of test.
ACCELERATING_FLICK = [(0, 0.0), (10, 2.0), (20, 6.0), (30, 14.0), (40, 30.0), (50, 54.0)]
# 4. The two edges of the sample walk, checked here so the Rust asserts
# Compose's answer rather than iris's own reading of the rule.
# (a) An old, fast burst outside the 100ms horizon, then a slow steady
# drag: the burst must not leak into the estimate.
OLD_BURST_THEN_STEADY = [(0, 0.0)] + [(10 + i * 10, 1000.0 + i) for i in range(11)]
# (b) The finger stops for 48ms and then lifts. The gap exceeds
# AssumePointerMoveStopped, so the walk breaks after one sample and
# there is no fling -- what stops a "park it and let go" from
# flinging at whatever speed the finger arrived with.
STOPPED_BEFORE_RELEASE = [(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)]
# 5. `sense.rs`'s own `drag_gesture_tests`: what `DragGesture` feeds for a
# press and two move frames, which is the fewest a fit can use.
TWO_MOVE_FRAMES = [(0, 0.0), (8, 100.0), (16, 220.0)]
# ... and one move frame, which Compose cannot fit either.
ONE_MOVE_FRAME = [(0, 0.0), (8, 100.0)]
# The phone: 1080x2424 at content_scale 2.55.
PHONE_DENSITY = 2.55
def report(name, samples):
v = calculate_velocity(samples)
print(f"{name}:")
print(f" samples (t_ms, position): {samples}")
print(f" Lsq2 (Compose's touch path): {v:.4f} px/s")
print(f" average (the old estimator): {average(samples):.4f} px/s")
print(f" impulse (non-touch, for ref): ", end="")
held = list(reversed(samples[-HISTORY_SIZE:]))
newest = held[0][0]
print(
f"{calculate_impulse_velocity([p for _, p in held], [-(newest - t) for t, _ in held], len(held), False) * 1000.0:.4f} px/s"
)
print()
if __name__ == "__main__":
print("Compose 1.12.0 touch velocity: VelocityTracker1D, Strategy.Lsq2,")
print("non-differential (positions), HistorySize=20, Horizon=100ms,")
print("AssumePointerMoveStopped=40ms, minSampleSize=3.\n")
report("flick-120hz.touch", FLICK_120HZ)
report("steady drag (5px/10ms)", STEADY_DRAG)
report("accelerating flick (deltas 2,4,8,16,24 per 10ms)", ACCELERATING_FLICK)
report("old burst then steady 1px/10ms", OLD_BURST_THEN_STEADY)
report("stopped 48ms before release", STOPPED_BEFORE_RELEASE)
report("press and two move frames", TWO_MOVE_FRAMES)
report("press and one move frame", ONE_MOVE_FRAME)
print("Clamps:")
print(f" maximum: {MAXIMUM_FLING_VELOCITY_DP_S} dp/s")
print(
f" = {MAXIMUM_FLING_VELOCITY_DP_S * PHONE_DENSITY:.1f} px/s at the phone's density {PHONE_DENSITY}"
)
print(f" minimum: none on the fling path; DefaultFlingBehavior skips |v| <= {FLING_MINIMUM_PX_S} px/s")
print()
print("Two samples only (a press and one move, the phone's 120Hz worst case):")
print(f" Lsq2 needs 3 and answers {calculate_velocity(FLICK_120HZ[:2]):.4f} px/s")
+9 -2
View File
@@ -4,9 +4,16 @@ version.workspace = true
edition.workspace = true edition.workspace = true
[dependencies] [dependencies]
winit = { workspace = true }
wgpu = { workspace = true } wgpu = { workspace = true }
# Only for `UiRenderNode::new`'s `push_error_scope`/`pop_error_scope` pair
# (renderer-creation error reporting, RUST.md's P0 phone-crash box) --
# `block_on` turns that one async pop into the same synchronous call shape
# `device_limits()`'s two callers already use for `request_adapter`/
# `request_device`, rather than making this crate's one entry point async.
pollster = { workspace = true }
bytemuck ={ workspace = true } bytemuck ={ workspace = true }
image = { workspace = true } image = { workspace = true }
cosmic-text = { workspace = true } parley = { workspace = true }
swash = { workspace = true }
fxhash = { workspace = true } fxhash = { workspace = true }
accesskit = { workspace = true }
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 Ryan L McIntyre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Rebuilds iris/core/assets/fonts/nerd_icons.ttf.
#
# iris draws its icons as glyphs in a Nerd Fonts subset it ships, rather
# than as ordinary Unicode out of whatever the platform resolved. Unicode's
# own geometric shapes are what this replaced: `tool.rs` set its disclosure
# mark with U+25B8/25BE/25B4, and once iris stopped bundling fonts
# (2026-09-07) Iris's phone drew an empty box for them and this VM drew a
# dot. UI_RULES: "don't rely on characters the platform might not have --
# ship the glyph or the asset rather than hoping."
#
# The whole symbols font is 3 MB for the handful below, so what is
# committed is a subset. Add a codepoint to GLYPHS *and* to
# `iris/core/src/icon.rs` (the two lists have to agree -- a codepoint in
# the Rust that this script did not subset is a glyph that silently isn't
# there), then run this and commit the result.
#
# Needs python3 and network access; fontTools is fetched into a temporary
# venv, so nothing has to be installed on the machine.
#
# The same arrangement as the Compose app's `app/build-icon-font.sh`, which
# this is copied from -- including the Mono face and the Material Design
# family, so an icon means the same thing in both apps. Copied rather than
# shared because most of it is the GLYPHS list, which has to differ: the
# point of subsetting is to ship only the codepoints one app draws.
set -euo pipefail
# Codepoint, then the Nerd Fonts glyph name it came from. Material Design
# Icons, as in the Compose app.
GLYPHS=(
U+F035D # md-menu_down -- a card that is open
U+F035F # md-menu_right -- a card that opens
U+F0360 # md-menu_up -- collapse this group again
)
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
here="$(cd "$(dirname "$0")" && pwd)"
out="$here/assets/fonts/nerd_icons.ttf"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
echo "Fetching $url"
curl -fsSL -o "$work/nf.zip" "$url"
python3 -c 'import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])' "$work/nf.zip" "$work"
python3 -m venv "$work/venv"
"$work/venv/bin/pip" -q install fonttools
unicodes="$(IFS=,; echo "${GLYPHS[*]}")"
mkdir -p "$(dirname "$out")"
# The Mono face, where every glyph is one em wide and one em tall, so two
# icons at the same font size are the same size without either being given
# one -- the same reason the Compose app's script takes it. It is also what
# makes an icon's box predictable beside a line of text.
"$work/venv/bin/pyftsubset" "$work/SymbolsNerdFontMono-Regular.ttf" \
--unicodes="$unicodes" \
--layout-features= \
--drop-tables+=DSIG \
--output-file="$out"
cp "$work/LICENSE" "$here/assets/fonts/NERD_FONTS_LICENSE.txt"
echo "Wrote $out ($(stat -c %s "$out") bytes) with ${#GLYPHS[@]} glyphs"
+1 -1
View File
@@ -1,4 +1,4 @@
use crate::{UiRsc, WidgetIdFn, WidgetLike, WeakWidget}; use crate::{UiRsc, WeakWidget, WidgetIdFn, WidgetLike};
pub trait WidgetAttr<Rsc, W: ?Sized> { pub trait WidgetAttr<Rsc, W: ?Sized> {
type Input; type Input;
+15
View File
@@ -79,6 +79,8 @@ type EventData<Rsc, E> = (E, Rc<dyn for<'a> EventFn<Rsc, <E as Event>::Data<'a>>
pub struct TypeEventManager<Rsc: HasEvents, E: Event> { pub struct TypeEventManager<Rsc: HasEvents, E: Event> {
// TODO: reduce visiblity!! // TODO: reduce visiblity!!
pub active: HashMap<LayerId, HashMap<WidgetId, E::State>>, pub active: HashMap<LayerId, HashMap<WidgetId, E::State>>,
/// This event's own input-wide state -- see [`Event::Global`].
pub global: E::Global,
map: HashMap<WidgetId, Vec<EventData<Rsc, E>>>, map: HashMap<WidgetId, Vec<EventData<Rsc, E>>>,
} }
@@ -107,6 +109,7 @@ impl<Rsc: HasEvents, E: Event> Default for TypeEventManager<Rsc, E> {
fn default() -> Self { fn default() -> Self {
Self { Self {
active: Default::default(), active: Default::default(),
global: Default::default(),
map: Default::default(), map: Default::default(),
} }
} }
@@ -135,6 +138,18 @@ impl<Rsc: HasEvents + 'static, E: Event> TypeEventManager<Rsc, E> {
)); ));
} }
/// The event lists this widget was registered with (`register`'s
/// `event` argument, one per call), without running anything. Lets a
/// caller ask "would this widget's registrations match the current
/// state" separately from actually dispatching to it -- used by
/// `sense.rs` to decide whether a widget genuinely consumes a scroll
/// or press this frame (so a lower layer can still receive it if not)
/// without that decision being conflated with "the cursor happens to
/// be over it," which is all `run_fn` running something tells you.
pub fn registered(&self, id: WidgetId) -> impl Iterator<Item = &E> {
self.map.get(&id).into_iter().flatten().map(|(e, _)| e)
}
pub fn run_fn<'a>( pub fn run_fn<'a>(
&mut self, &mut self,
id: impl IdLike, id: impl IdLike,
+14
View File
@@ -9,6 +9,20 @@ pub use rsc::*;
pub trait Event: Sized + 'static + Clone { pub trait Event: Sized + 'static + Clone {
type Data<'a>: Clone = (); type Data<'a>: Clone = ();
type State: Default = (); type State: Default = ();
/// State this event owns that belongs to no single widget -- what the
/// thing dispatching the event knows about the *input*, rather than
/// about a listener. `()` for almost every event; the cursor's is
/// `iris::sense::PointerInput` (which widget holds pointer capture,
/// and who is tracking the press in flight).
///
/// It lives here so that such state has one owner, reached by `&mut`
/// through the event manager, instead of being parked on whatever
/// structure a handler happens to be able to reach and guarded with a
/// lock. Iris asked for that on 2026-09-08, of the pointer capture
/// that used to sit in a `Mutex` on `UiRenderState`: "everything
/// global should be stored in the general input handler, not in
/// specific senses with locking stuff."
type Global: Default = ();
#[allow(unused_variables)] #[allow(unused_variables)]
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> { fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
Some(data.clone()) Some(data.clone())
+39
View File
@@ -0,0 +1,39 @@
//! The icons iris draws, as codepoints in the Nerd Fonts subset it ships.
//!
//! **Why a bundled font rather than ordinary Unicode**: the disclosure
//! mark used to be U+25B8/25BE/25B4 out of whatever face the platform
//! resolved, and once iris stopped bundling fonts (DECISIONS.md,
//! 2026-09-07) Iris's phone drew an empty box for them and this VM drew a
//! dot. UI_RULES' answer is not to avoid glyphs but to ship them, which is
//! also what the Compose app has always done for its icons
//! (`app/build-icon-font.sh`, `NerdIcons.kt`) -- the same Material Design
//! family, so an icon means the same thing in both apps.
//!
//! **Why not vector assets or drawn shapes**: an icon beside a line of
//! text wants that line's size, colour and baseline, and text gets all
//! three for free. This replaced `iris::widget::mark`, which drew the
//! triangle into a texture: correct, but one shape, and every further icon
//! would have been another bespoke rasteriser.
//!
//! Each constant here has to have a matching codepoint in
//! `iris/core/build-icon-font.sh`'s `GLYPHS`; a codepoint here that the
//! script did not subset is a glyph that silently isn't there. The subset
//! is the font's **Mono** face, where every glyph is one em wide and one
//! em tall, so two icons at one font size are one size without either
//! being given one -- and why an icon looks smaller than text at the same
//! size, since the glyph is drawn inside that em rather than filling it.
//!
//! Draw one with [`crate::Family::Icons`]:
//!
//! ```ignore
//! text(icon::OPEN, 12.0, MUTED).family(Family::Icons)
//! ```
/// `md-menu_down` -- a filled triangle pointing down: this card is open.
pub const OPEN: &str = "\u{F035D}";
/// `md-menu_right` -- pointing right: this card opens.
pub const CLOSED: &str = "\u{F035F}";
/// `md-menu_up` -- pointing up: fold this group of cards away again.
pub const COLLAPSE: &str = "\u{F0360}";
+1 -3
View File
@@ -2,12 +2,9 @@
#![feature(const_ops)] #![feature(const_ops)]
#![feature(const_trait_impl)] #![feature(const_trait_impl)]
#![feature(const_convert)] #![feature(const_convert)]
#![feature(map_try_insert)]
#![feature(unboxed_closures)] #![feature(unboxed_closures)]
#![feature(fn_traits)] #![feature(fn_traits)]
#![feature(const_cmp)]
#![feature(const_destruct)] #![feature(const_destruct)]
#![feature(portable_simd)]
#![feature(associated_type_defaults)] #![feature(associated_type_defaults)]
#![feature(unsize)] #![feature(unsize)]
#![feature(coerce_unsized)] #![feature(coerce_unsized)]
@@ -22,6 +19,7 @@ mod render;
mod ui; mod ui;
mod widget; mod widget;
pub mod icon;
pub mod util; pub mod util;
pub use attr::*; pub use attr::*;
+5 -5
View File
@@ -5,19 +5,19 @@ pub const trait UiNum {
fn to_f32(self) -> f32; fn to_f32(self) -> f32;
} }
impl const UiNum for f32 { const impl UiNum for f32 {
fn to_f32(self) -> f32 { fn to_f32(self) -> f32 {
self self
} }
} }
impl const UiNum for u32 { const impl UiNum for u32 {
fn to_f32(self) -> f32 { fn to_f32(self) -> f32 {
self as f32 self as f32
} }
} }
impl const UiNum for i32 { const impl UiNum for i32 {
fn to_f32(self) -> f32 { fn to_f32(self) -> f32 {
self as f32 self as f32
} }
@@ -27,7 +27,7 @@ pub const fn vec2(x: impl const UiNum, y: impl const UiNum) -> Vec2 {
Vec2::new(x.to_f32(), y.to_f32()) Vec2::new(x.to_f32(), y.to_f32())
} }
impl<T: const UiNum + Copy> const From<T> for Vec2 { const impl<T: const UiNum + Copy> From<T> for Vec2 {
fn from(v: T) -> Self { fn from(v: T) -> Self {
Self { Self {
x: v.to_f32(), x: v.to_f32(),
@@ -36,7 +36,7 @@ impl<T: const UiNum + Copy> const From<T> for Vec2 {
} }
} }
impl<T: const UiNum, U: const UiNum> const From<(T, U)> for Vec2 const impl<T: const UiNum, U: const UiNum> From<(T, U)> for Vec2
where where
(T, U): const Destruct, (T, U): const Destruct,
{ {
+1 -1
View File
@@ -187,7 +187,7 @@ impl From<CardinalAlign> for Align {
} }
} }
impl const From<RegionAlign> for UiVec2 { const impl From<RegionAlign> for UiVec2 {
fn from(align: RegionAlign) -> Self { fn from(align: RegionAlign) -> Self {
Self::rel(align.rel()) Self::rel(align.rel())
} }
+3 -3
View File
@@ -1,6 +1,6 @@
use super::*; use super::*;
#[derive(Copy, Clone, Eq, PartialEq)] #[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum Axis { pub enum Axis {
X, X,
Y, Y,
@@ -74,14 +74,14 @@ pub const trait AxisT {
} }
pub struct XAxis; pub struct XAxis;
impl const AxisT for XAxis { const impl AxisT for XAxis {
fn get() -> Axis { fn get() -> Axis {
Axis::X Axis::X
} }
} }
pub struct YAxis; pub struct YAxis;
impl const AxisT for YAxis { const impl AxisT for YAxis {
fn get() -> Axis { fn get() -> Axis {
Axis::Y Axis::Y
} }
+87 -7
View File
@@ -9,7 +9,31 @@ pub struct Size {
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct Len { pub struct Len {
/// Physical pixels -- a raw device pixel, unaffected by the display's
/// density. Rare to want directly (a hairline border is the usual
/// case); most sizes should be `dp` instead. See `dp`'s own doc for why
/// the two are kept separate rather than one field a caller has to
/// remember to pre-multiply.
pub abs: f32, pub abs: f32,
/// Density-independent pixels -- Android's `dp` / CSS's reference pixel
/// (1 unit = 1/160in), resolved against the display's density at
/// layout time (`apply_rest`'s `density` parameter) rather than at the
/// point a widget is built, since density is a property of the device
/// this ends up running on, not of the widget tree. This is the unit
/// IRIS_TODO.md's "a density-independent length unit" item asked for,
/// 2026-09-06: before it existed, every size in the tree was `abs`
/// (physical pixels), and the only way to make a 16px design draw at
/// the right *size* on a denser display was a single global multiply
/// applied to the whole rendered scene after layout -- which is also
/// what made text blurry (RUST.md's P0 box, "blurry ... glyphs drawn
/// at logical size and stretched by the scale"): a glyph rasterised at
/// 16 physical px and then stretched 3x by that global multiply is a
/// 48px area sampled from a 16px bitmap. Resolving `dp` per-length at
/// layout time instead means the font size handed to the text shaper
/// is already the physical size (`16.0.dp() * 3.0`), so the glyph
/// atlas rasterises at the display's real resolution and nothing
/// downstream needs to stretch anything.
pub dp: f32,
pub rel: f32, pub rel: f32,
pub rest: f32, pub rest: f32,
} }
@@ -67,10 +91,10 @@ impl Size {
} }
} }
pub fn to_uivec2(self) -> UiVec2 { pub fn to_uivec2(self, density: f32) -> UiVec2 {
UiVec2 { UiVec2 {
x: self.x.apply_rest(), x: self.x.apply_rest(density),
y: self.y.apply_rest(), y: self.y.apply_rest(density),
} }
} }
@@ -98,26 +122,66 @@ impl Size {
impl Len { impl Len {
pub const ZERO: Self = Self { pub const ZERO: Self = Self {
abs: 0.0, abs: 0.0,
dp: 0.0,
rel: 0.0, rel: 0.0,
rest: 0.0, rest: 0.0,
}; };
pub const REST: Self = Self { pub const REST: Self = Self {
abs: 0.0, abs: 0.0,
dp: 0.0,
rel: 0.0, rel: 0.0,
rest: 1.0, rest: 1.0,
}; };
pub fn apply_rest(&self) -> UiScalar { /// Resolves to a `UiScalar`, folding `dp` into `abs` pixels against
/// `density` (physical pixels per dp -- 1.0 on a desktop or an
/// unscaled display, `content_scale` on Android; see `dp`'s field
/// doc). Every other component of `Len` is already resolution-
/// independent (`rel` is a fraction of the parent; `rest` becomes a
/// fraction too, below), so `density` only ever touches this one term.
pub fn apply_rest(&self, density: f32) -> UiScalar {
UiScalar { UiScalar {
rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 }, rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 },
abs: self.abs, abs: self.abs + self.dp * density,
}
}
/// The same fold as [`Self::apply_rest`] but staying a `Len`, so
/// `rest` survives: `dp` becomes physical pixels and every other
/// component is left alone.
///
/// **A `Len` a widget *reports* must have been through this.** `dp` is
/// an input unit -- a number the widget author wrote -- and the
/// containers that consume a reported length read `abs`/`rel`/`rest`
/// directly (`Span::draw`'s placement arithmetic, `Pad`'s addition),
/// so a reported `dp` is silently worth zero. That is what made the
/// composer's bar collapse to nothing the moment its content grew past
/// `MaxSize`'s cap: the cap was `dp(168)` and was returned unresolved,
/// so the bar was given a slot of 0 and the field inside it was panned
/// out of a container measured at -63px. `UiRenderState::draw_inner`
/// debug-asserts the invariant after every `Widget::draw`.
pub fn fold_dp(&self, density: f32) -> Self {
Self {
abs: self.abs + self.dp * density,
dp: 0.0,
rel: self.rel,
rest: self.rest,
} }
} }
pub fn abs(abs: impl UiNum) -> Self { pub fn abs(abs: impl UiNum) -> Self {
Self { Self {
abs: abs.to_f32(), abs: abs.to_f32(),
dp: 0.0,
rel: 0.0,
rest: 0.0,
}
}
pub fn dp(dp: impl UiNum) -> Self {
Self {
abs: 0.0,
dp: dp.to_f32(),
rel: 0.0, rel: 0.0,
rest: 0.0, rest: 0.0,
} }
@@ -125,6 +189,7 @@ impl Len {
pub fn rel(rel: impl UiNum) -> Self { pub fn rel(rel: impl UiNum) -> Self {
Self { Self {
abs: 0.0, abs: 0.0,
dp: 0.0,
rel: rel.to_f32(), rel: rel.to_f32(),
rest: 0.0, rest: 0.0,
} }
@@ -132,6 +197,7 @@ impl Len {
pub fn rest(ratio: impl UiNum) -> Self { pub fn rest(ratio: impl UiNum) -> Self {
Self { Self {
abs: 0.0, abs: 0.0,
dp: 0.0,
rel: 0.0, rel: 0.0,
rest: ratio.to_f32(), rest: ratio.to_f32(),
} }
@@ -144,6 +210,15 @@ pub mod len_fns {
pub fn abs(abs: impl UiNum) -> Len { pub fn abs(abs: impl UiNum) -> Len {
Len { Len {
abs: abs.to_f32(), abs: abs.to_f32(),
dp: 0.0,
rel: 0.0,
rest: 0.0,
}
}
pub fn dp(dp: impl UiNum) -> Len {
Len {
abs: 0.0,
dp: dp.to_f32(),
rel: 0.0, rel: 0.0,
rest: 0.0, rest: 0.0,
} }
@@ -151,6 +226,7 @@ pub mod len_fns {
pub fn rel(rel: impl UiNum) -> Len { pub fn rel(rel: impl UiNum) -> Len {
Len { Len {
abs: 0.0, abs: 0.0,
dp: 0.0,
rel: rel.to_f32(), rel: rel.to_f32(),
rest: 0.0, rest: 0.0,
} }
@@ -158,14 +234,15 @@ pub mod len_fns {
pub fn rest(ratio: impl UiNum) -> Len { pub fn rest(ratio: impl UiNum) -> Len {
Len { Len {
abs: 0.0, abs: 0.0,
dp: 0.0,
rel: 0.0, rel: 0.0,
rest: ratio.to_f32(), rest: ratio.to_f32(),
} }
} }
} }
impl_op!(Len Add add; abs rel rest); impl_op!(Len Add add; abs dp rel rest);
impl_op!(Len Sub sub; abs rel rest); impl_op!(Len Sub sub; abs dp rel rest);
impl_op!(Size Add add; x y); impl_op!(Size Add add; x y);
impl_op!(Size Sub sub; x y); impl_op!(Size Sub sub; x y);
@@ -187,6 +264,9 @@ impl std::fmt::Display for Len {
if self.abs != 0.0 { if self.abs != 0.0 {
write!(f, "{} abs;", self.abs)?; write!(f, "{} abs;", self.abs)?;
} }
if self.dp != 0.0 {
write!(f, "{} dp;", self.dp)?;
}
if self.rel != 0.0 { if self.rel != 0.0 {
write!(f, "{} rel;", self.rel)?; write!(f, "{} rel;", self.rel)?;
} }
+3 -3
View File
@@ -124,13 +124,13 @@ impl Display for UiVec2 {
impl_op!(UiVec2 Add add; x y); impl_op!(UiVec2 Add add; x y);
impl_op!(UiVec2 Sub sub; x y); impl_op!(UiVec2 Sub sub; x y);
impl const From<Vec2> for UiVec2 { const impl From<Vec2> for UiVec2 {
fn from(abs: Vec2) -> Self { fn from(abs: Vec2) -> Self {
Self::abs(abs) Self::abs(abs)
} }
} }
impl<T: const UiNum, U: const UiNum> const From<(T, U)> for UiVec2 const impl<T: const UiNum, U: const UiNum> From<(T, U)> for UiVec2
where where
(T, U): const Destruct, (T, U): const Destruct,
{ {
@@ -421,7 +421,7 @@ impl Display for UiRegion {
} }
} }
#[derive(Debug)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct PixelRegion { pub struct PixelRegion {
pub top_left: Vec2, pub top_left: Vec2,
pub bot_right: Vec2, pub bot_right: Vec2,
+11 -2
View File
@@ -10,6 +10,15 @@ pub struct Color<T> {
pub a: T, pub a: T,
} }
/// Required by parley's `Brush`, which every text style is generic over. Opaque
/// black rather than transparent: a brush that was never set should be visible
/// and obviously unstyled, not invisible.
impl<T: ColorNum> Default for Color<T> {
fn default() -> Self {
Self::BLACK
}
}
impl<T: ColorNum> Color<T> { impl<T: ColorNum> Color<T> {
pub const BLACK: Self = Self::rgb(T::MIN, T::MIN, T::MIN); pub const BLACK: Self = Self::rgb(T::MIN, T::MIN, T::MIN);
pub const WHITE: Self = Self::rgb(T::MAX, T::MAX, T::MAX); pub const WHITE: Self = Self::rgb(T::MAX, T::MAX, T::MAX);
@@ -144,7 +153,7 @@ impl ColorNum for f32 {
unsafe impl bytemuck::Pod for Color<u8> {} unsafe impl bytemuck::Pod for Color<u8> {}
impl const F32Conversion for f32 { const impl F32Conversion for f32 {
fn to(self) -> f32 { fn to(self) -> f32 {
self self
} }
@@ -153,7 +162,7 @@ impl const F32Conversion for f32 {
} }
} }
impl const F32Conversion for u8 { const impl F32Conversion for u8 {
fn to(self) -> f32 { fn to(self) -> f32 {
self as f32 / 255.0 self as f32 / 255.0
} }
+5 -19
View File
@@ -1,9 +1,6 @@
use std::ops::{Index, IndexMut}; use std::ops::{Index, IndexMut};
use crate::{ use crate::{render::LayerOrder, util::to_mut};
render::{MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst, Primitives},
util::to_mut,
};
pub type LayerId = usize; pub type LayerId = usize;
@@ -39,7 +36,10 @@ struct Child {
tail: usize, tail: usize,
} }
pub type PrimitiveLayers = Layers<Primitives>; /// The draw order of every layer. The primitives themselves live in one
/// arena beside this (`UiRenderState::primitives`); a layer names the
/// slots it draws, which is what its vertex buffer is.
pub type PrimitiveLayers = Layers<LayerOrder>;
impl<T: Default> Layers<T> { impl<T: Default> Layers<T> {
pub fn new() -> Layers<T> { pub fn new() -> Layers<T> {
@@ -119,20 +119,6 @@ impl<T: Default> Layers<T> {
} }
} }
impl PrimitiveLayers {
pub fn write<P: Primitive>(
&mut self,
layer: LayerId,
info: PrimitiveInst<P>,
) -> PrimitiveHandle {
self[layer].write(layer, info)
}
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
self[h.layer].free(h)
}
}
impl<T: Default> Default for Layers<T> { impl<T: Default> Default for Layers<T> {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
+724 -138
View File
@@ -1,60 +1,444 @@
use crate::{Align, RegionAlign, TextureHandle, Textures, UiColor, util::Vec2}; use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiColor, util::Vec2};
use cosmic_text::{ use parley::{
Attrs, AttrsList, Buffer, CacheKey, Color, Family, FontSystem, Metrics, Placement, SwashCache, Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
SwashContent, GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
fontique::Blob,
};
use std::ops::Range;
use std::sync::Arc;
use swash::{
FontRef,
scale::{Render, ScaleContext, Source, StrikeWith},
zeno::{Format, Vector},
}; };
use image::{DynamicImage, GenericImageView, RgbaImage};
use std::simd::{Simd, num::SimdUint};
/// TODO: properly wrap this /// The icon font iris ships: the Nerd Fonts Symbols **Mono** subset built
pub mod text_lib { /// by `iris/core/build-icon-font.sh`, holding only the codepoints
pub use cosmic_text::*; /// `crate::icon` names (992 bytes for three glyphs today).
///
/// This is the one font bundled here, and it is not a text font: body and
/// monospace text still come from the platform's own collection
/// (DECISIONS.md, 2026-09-07). An icon is the opposite case -- a small,
/// closed set of codepoints no system font is guaranteed to have -- which
/// is the same division the Compose app makes.
const NERD_ICONS: &[u8] = include_bytes!("../../assets/fonts/nerd_icons.ttf");
/// What starting up found about text rendering, for the on-screen
/// Diagnostics page and the one startup log line (RUST.md's P0 box, "log
/// once at startup ... the number of font families found, the default
/// family resolved"). Built once by `TextData::font_diagnostics` --
/// `Default::default` still exists for callers (tests, examples) that
/// don't need the report.
#[derive(Clone, Debug)]
pub struct FontDiagnostics {
/// `Collection::family_names().count()` after registering the bundled
/// fonts -- system families plus the two bundled ones.
pub families_found: usize,
/// The family `GenericFamily::SansSerif` resolves to first -- the
/// bundled "Noto Sans" unless registration itself failed.
pub default_family: Option<String>,
/// The family `GenericFamily::Monospace` resolves to first.
pub default_mono_family: Option<String>,
/// One resolved family name per style axis this crate actually uses
/// (`SpanStyle::bold`/`italic`), so a report can say plainly whether a
/// bold/italic request is landing on a real face rather than being
/// silently absorbed by whatever the sans-serif default resolves to
/// for every weight (RUST.md's P0 box, "bold words render as blank
/// gaps" -- a family that resolves but has no distinct bold face is
/// exactly what produced that).
pub regular_resolved: Option<String>,
pub bold_resolved: Option<String>,
pub italic_resolved: Option<String>,
pub mono_resolved: Option<String>,
/// The family the bundled icon font registered under, or `None` if
/// registering it failed. Reported rather than assumed: it is the one
/// font iris ships, so `None` is a broken build and must not look
/// like a device that happens to lack a face.
pub icon_family: Option<String>,
} }
/// Everything text needs that outlives one string: the font collection, the
/// layout scratch space, the glyph rasteriser and the atlas they fill.
pub struct TextData { pub struct TextData {
pub font_system: FontSystem, pub font_cx: FontContext,
pub swash_cache: SwashCache, pub layout_cx: LayoutContext<UiColor>,
glyph_cache: Vec<(Placement, CacheKey, Color)>, scale_cx: ScaleContext,
pub atlas: GlyphAtlas,
/// Physical pixels per dp -- a second copy of
/// `UiRenderState::density`, kept here too because `TextEditCtx::layout`
/// (cursor movement and hit-testing, `widget/text/edit.rs`) shapes text
/// from an event callback that has a `TextData` but no `Painter`, so it
/// has nowhere else to read the display's density from. Both copies are
/// set together, from the one place either backend learns the real
/// value (`android::view::new_peer`); this is the same accepted
/// duplication as `AndroidRenderer::content_scale`; a single source of
/// truth would mean carrying a `Painter` (or output size) into every
/// input handler for the sake of one field.
pub density: f32,
/// The family name [`NERD_ICONS`] registered under, which is what
/// [`Family::Icons`] resolves to. `None` only if registering the
/// bundled font failed, which is a broken build rather than a
/// platform difference -- said in the startup diagnostics rather than
/// silently drawn as tofu.
pub icon_family: Option<String>,
} }
impl Default for TextData { impl Default for TextData {
/// Text comes entirely from the platform's own font collection --
/// `FontContext::new()` builds a `fontique::Collection` with
/// `CollectionOptions::system_fonts` on by default, which is real
/// discovery on both targets this crate ships on: Android's backend
/// parses `/system/fonts` and `/system/etc/fonts.xml` and maps
/// `SansSerif`/`SystemUi` to `["Roboto Flex", "Roboto", "Noto Sans"]`
/// and `Monospace` to the platform's `"monospace"` alias; the desktop
/// build's backend is fontconfig. No font is bundled or registered
/// here -- see DECISIONS.md's 2026-09-07 entry for why (matching what
/// the Compose app does: it takes body/monospace text from
/// `FontFamily.Default`/`FontFamily.Monospace`, i.e. Android's Roboto
/// and its platform monospace face, and ships no text font of its own,
/// only its committed Nerd Fonts icon subset for fixed glyphs).
fn default() -> Self { fn default() -> Self {
let mut font_cx = FontContext::new();
patch_android_monospace(&mut font_cx);
let icon_family = register_icon_font(&mut font_cx);
Self { Self {
font_system: FontSystem::new(), font_cx,
swash_cache: SwashCache::new(), layout_cx: LayoutContext::new(),
glyph_cache: Default::default(), scale_cx: ScaleContext::new(),
atlas: GlyphAtlas::default(),
density: 1.0,
icon_family,
} }
} }
} }
#[derive(Clone, Copy)] /// Registers the bundled icon font as an ordinary named family and
/// answers the name it registered under -- read back from the collection
/// rather than written down here, so the name cannot drift from the file
/// (`build-icon-font.sh` takes whatever face the Nerd Fonts release
/// ships).
///
/// A *named* family rather than a generic one: nothing should fall back
/// to it for ordinary text, and nothing should fall back out of it for an
/// icon -- a system face that happens to have one of these codepoints
/// would draw somebody else's picture.
fn register_icon_font(font_cx: &mut FontContext) -> Option<String> {
let blob = Blob::new(Arc::new(NERD_ICONS));
let id = font_cx
.collection
.register_fonts(blob, None)
.into_iter()
.map(|(id, _)| id)
.next()?;
font_cx.collection.family_name(id).map(str::to_string)
}
/// Works around `fontique` 0.11.1's Android backend never resolving
/// `GenericFamily::Monospace` (confirmed against
/// `fontique-0.11.1/src/backend/android.rs`'s `SystemFonts::new`, and still
/// present on `linebender/parley`'s `main` as of 2026-09-07, so there is no
/// released fix to bump to yet -- see DECISIONS.md's 2026-09-07 entry,
/// "Platform fonts," for the full account). Two bugs stack, not one:
/// `DEFAULT_GENERIC_FAMILIES` looks up the name `"monospace"` *before*
/// `fonts.xml` is parsed into that same name map, and even after parsing,
/// AOSP's `fonts.xml` names it with a `<family name="monospace">` element
/// (not an `<alias>`) whose `<font>` children the backend's own parser
/// does not read (a `TODO` in that match arm) -- so the name gets a
/// `FamilyId` with no font data behind it, and `family_by_name("monospace")`
/// comes back empty too. Confirmed on this checkout's emulator: `adb pull
/// /system/etc/fonts.xml` shows
/// `<family name="monospace"><font weight="400"
/// style="normal">DroidSansMono.ttf</font></family>` with no matching
/// alias.
///
/// So this reads `fonts.xml` itself (already on-device, already the
/// authority Compose's own `Typeface.MONOSPACE` resolves through) for the
/// filename that declaration names, then finds which of fontique's
/// *actually* scanned families (from `/system/fonts`, which do carry real
/// font data, just under whatever name the font's own metadata gives it --
/// "Droid Sans Mono" here, but that name is never hardcoded) owns a font
/// file with that name, and registers that family as the `Monospace`
/// generic the way the backend itself would have if its parser had reified
/// the declaration. A no-op if the family is somehow already resolved
/// (future fontique) or nothing matches (no `fonts.xml`, e.g. a headless
/// test, or a device that names it some other way).
#[cfg(target_os = "android")]
fn patch_android_monospace(font_cx: &mut FontContext) {
use parley::fontique::SourceKind;
let already_resolved = font_cx
.collection
.generic_families(GenericFamily::Monospace)
.next()
.is_some();
if already_resolved {
return;
}
let Some(target_file) = android_monospace_font_filename() else {
return;
};
let names: Vec<String> = font_cx
.collection
.family_names()
.map(str::to_string)
.collect();
for name in names {
let Some(id) = font_cx.collection.family_id(&name) else {
continue;
};
let Some(info) = font_cx.collection.family(id) else {
continue;
};
let Some(font) = info.default_font() else {
continue;
};
let SourceKind::Path(path) = font.source().kind() else {
continue;
};
if path.file_name().and_then(|f| f.to_str()) == Some(target_file.as_str()) {
font_cx
.collection
.append_generic_families(GenericFamily::Monospace, std::iter::once(id));
return;
}
}
}
/// Reads the font filename `fonts.xml` names for its `"monospace"` family
/// (e.g. `"DroidSansMono.ttf"`), by plain substring search rather than a
/// real XML parser -- a new dependency for one well-known, stable AOSP file
/// whose structure fontique itself already parses with a full parser one
/// module over. Not a general XML reader; assumes the file has exactly one
/// `<family name="monospace">` element with at least one `<font>` child,
/// which is the format on every AOSP `fonts.xml` this was checked against.
#[cfg(target_os = "android")]
fn android_monospace_font_filename() -> Option<String> {
let android_root = std::env::var("ANDROID_ROOT").unwrap_or_else(|_| "/system".to_string());
let xml =
std::fs::read_to_string(std::path::Path::new(&android_root).join("etc/fonts.xml")).ok()?;
let family_start = xml.find("<family name=\"monospace\">")?;
let block = &xml[family_start..];
let block = &block[..block.find("</family>")?];
let font_tag = block.find("<font")?;
let after_tag = &block[font_tag..];
let content_start = after_tag.find('>')? + 1;
let content = &after_tag[content_start..];
let filename = content[..content.find('<')?].trim();
(!filename.is_empty()).then(|| filename.to_string())
}
#[cfg(not(target_os = "android"))]
fn patch_android_monospace(_font_cx: &mut FontContext) {}
impl TextData {
/// [`Family::Icons`] as the name the bundled font actually registered
/// under; everything else unchanged.
///
/// Cloned rather than borrowed because the caller needs it while the
/// layout builder holds `&mut self` -- a `String` per shaped icon run,
/// paid only when the layout is rebuilt.
pub fn resolve_family(&self, family: &Family) -> Family {
match family {
Family::Icons => self
.icon_family
.clone()
.map_or(Family::Icons, Family::Named),
other => other.clone(),
}
}
/// Builds the startup report -- see `FontDiagnostics`. Queries the
/// collection directly (`fontique::Query`) rather than shaping a real
/// string, since all that's needed is which family each axis lands on.
pub fn font_diagnostics(&mut self) -> FontDiagnostics {
use parley::fontique::{Attributes, FontWidth, QueryStatus};
let families_found = self.font_cx.collection.family_names().count();
let default_family_id = self
.font_cx
.collection
.generic_families(GenericFamily::SansSerif)
.next();
let default_family = default_family_id
.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string));
let default_mono_family_id = self
.font_cx
.collection
.generic_families(GenericFamily::Monospace)
.next();
let default_mono_family = default_mono_family_id
.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string));
// Resolves the family a (generic family, weight, style) query lands
// on, without holding the `Query`'s borrow of `collection` across
// the `family_name` lookup that needs it back -- the `FamilyId` is
// captured out of the closure first, then looked up once `query`
// (and its borrow) has been dropped.
let mut resolve_family =
|generic: GenericFamily, weight: FontWeight, style: FontStyle| -> Option<String> {
let mut family_id = None;
{
let mut query = self
.font_cx
.collection
.query(&mut self.font_cx.source_cache);
query.set_families([generic]);
query.set_attributes(Attributes {
width: FontWidth::NORMAL,
style,
weight,
});
query.matches_with(|font| {
family_id = Some(font.family.0);
QueryStatus::Stop
});
}
family_id.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string))
};
let regular_resolved = resolve_family(
GenericFamily::SansSerif,
FontWeight::NORMAL,
FontStyle::Normal,
);
let bold_resolved = resolve_family(
GenericFamily::SansSerif,
FontWeight::BOLD,
FontStyle::Normal,
);
let italic_resolved = resolve_family(
GenericFamily::SansSerif,
FontWeight::NORMAL,
FontStyle::Italic,
);
let mono_resolved = resolve_family(
GenericFamily::Monospace,
FontWeight::NORMAL,
FontStyle::Normal,
);
FontDiagnostics {
families_found,
default_family,
default_mono_family,
regular_resolved,
bold_resolved,
italic_resolved,
mono_resolved,
icon_family: self.icon_family.clone(),
}
}
}
/// Which family to ask for. Kept as an owned name rather than parley's
/// borrowed `FontFamily<'_>` so that a widget can hold one without a lifetime.
#[derive(Clone, PartialEq)]
pub enum Family {
SansSerif,
Serif,
Monospace,
/// The bundled icon font -- see [`crate::icon`] for what is in it.
/// Named as an intention rather than as a font name because only
/// [`TextData`] knows what the file registered as; it resolves this
/// during shaping ([`TextData::resolve_family`]).
Icons,
Named(String),
}
impl Family {
fn family(&self) -> FontFamily<'_> {
let name = match self {
Self::SansSerif => FontFamilyName::Generic(GenericFamily::SansSerif),
Self::Serif => FontFamilyName::Generic(GenericFamily::Serif),
Self::Monospace => FontFamilyName::Generic(GenericFamily::Monospace),
// Only reachable if `resolve_family` did not run, which no
// shaping path allows -- and sans-serif is the honest answer
// for a build whose icon font failed to register: the reader
// gets the platform's own tofu rather than a wrong picture.
Self::Icons => FontFamilyName::Generic(GenericFamily::SansSerif),
Self::Named(name) => FontFamilyName::Named(name.as_str().into()),
};
FontFamily::Single(name)
}
}
/// One styled run inside a `TextBuffer`, overriding `TextAttrs`' base style
/// over `range` (a byte range into the buffer's text). Every field is
/// optional so a span only says what it changes -- e.g. a link span sets
/// `color` and `underline` and leaves weight/family at the paragraph's own
/// default. This is I5's answer to RUST.md's inline-rich-text ceiling
/// (`masonry/src/widgets/text_area.rs`'s `StyleSet` is one style for the
/// whole editor, with `// TODO: RichTextInput` beside it): parley's own
/// `RangedBuilder::push` already takes a style and a range, so per-span
/// bold/italic/monospace/colour/underline only needed plumbing this struct
/// through to it and giving each glyph its own colour at draw time (see
/// `PlacedGlyph::color` and `TextData::place` below) instead of the one
/// `RenderedText::color` every glyph used to share.
#[derive(Clone, PartialEq)]
pub struct SpanStyle {
pub range: Range<usize>,
pub color: Option<UiColor>,
pub family: Option<Family>,
/// Overrides `TextAttrs::font_size` for just this range -- what lets a
/// heading inside a transcript row's single `TextEdit` be bigger than
/// the paragraph text around it, so a whole markdown-folded row (block
/// and inline styling both) can stay one selectable text buffer instead
/// of one widget per block.
pub font_size: Option<f32>,
pub bold: bool,
pub italic: bool,
pub underline: bool,
}
impl SpanStyle {
pub fn new(range: Range<usize>) -> Self {
Self {
range,
color: None,
family: None,
font_size: None,
bold: false,
italic: false,
underline: false,
}
}
pub fn color(mut self, color: UiColor) -> Self {
self.color = Some(color);
self
}
pub fn family(mut self, family: Family) -> Self {
self.family = Some(family);
self
}
pub fn font_size(mut self, size: f32) -> Self {
self.font_size = Some(size);
self
}
pub fn bold(mut self) -> Self {
self.bold = true;
self
}
pub fn italic(mut self) -> Self {
self.italic = true;
self
}
pub fn underline(mut self) -> Self {
self.underline = true;
self
}
}
#[derive(Clone, PartialEq)]
pub struct TextAttrs { pub struct TextAttrs {
pub color: UiColor, pub color: UiColor,
pub font_size: f32, pub font_size: f32,
pub line_height: f32, pub line_height: f32,
pub family: Family<'static>, pub family: Family,
pub wrap: bool, pub wrap: bool,
/// inner alignment of text region (within where it's drawn) /// inner alignment of text region (within where it's drawn)
pub align: RegionAlign, pub align: RegionAlign,
} }
impl TextAttrs { pub const LINE_HEIGHT_MULT: f32 = 1.1;
pub fn apply(&self, font_system: &mut FontSystem, buf: &mut Buffer, width: Option<f32>) {
buf.set_metrics_and_size(
font_system,
Metrics::new(self.font_size, self.line_height),
width,
None,
);
let attrs = Attrs::new().family(self.family);
let list = AttrsList::new(&attrs);
for line in &mut buf.lines {
line.set_attrs_list(list.clone());
}
}
}
pub type TextBuffer = Buffer;
impl Default for TextAttrs { impl Default for TextAttrs {
fn default() -> Self { fn default() -> Self {
@@ -70,122 +454,324 @@ impl Default for TextAttrs {
} }
} }
pub const LINE_HEIGHT_MULT: f32 = 1.1; /// A string together with its laid-out form.
///
/// The text and the layout live in one place because parley's `Layout` borrows
/// nothing but is only meaningful against the string it was built from: keeping
/// them apart is how they get out of step.
pub struct TextBuffer {
text: String,
layout: Layout<UiColor>,
spans: Vec<SpanStyle>,
/// What the current layout was built for, so `shape` can decline to redo
/// work that would come out the same. Spans are not part of this key --
/// `set_spans` forces `shaped` to `None` directly, the same way `edit`
/// does, since spans change far less often than a naive equality check
/// on the whole `Vec` would cost to compute every frame.
shaped: Option<(TextAttrs, Option<f32>, f32)>,
}
impl TextBuffer {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
layout: Layout::new(),
spans: Vec::new(),
shaped: None,
}
}
/// Replace this buffer's per-range style overrides (I5's rich text --
/// see `SpanStyle`). Invalidates the layout unconditionally, mirroring
/// `set_text`.
pub fn set_spans(&mut self, spans: Vec<SpanStyle>) {
self.spans = spans;
self.shaped = None;
}
pub fn new_empty() -> Self {
Self::new("")
}
pub fn text(&self) -> &str {
&self.text
}
pub fn layout(&self) -> &Layout<UiColor> {
&self.layout
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
pub fn set_text(&mut self, text: impl Into<String>) {
let text = text.into();
if text != self.text {
self.text = text;
self.shaped = None;
}
}
/// Edit the string in place; invalidates the layout unconditionally, since
/// the caller is assumed to have changed something.
pub fn edit(&mut self) -> &mut String {
self.shaped = None;
&mut self.text
}
pub fn size(&self) -> Vec2 {
Vec2::new(self.layout.width(), self.layout.height())
}
/// Lay the text out, unless it is already laid out for these
/// attributes, this width and this density.
///
/// **`attrs.font_size`/`line_height` and every span's own `font_size`
/// are density-independent (dp) units, multiplied by `density` here --
/// the one place text crosses from the widget tree's dp sizes into the
/// physical pixels the shaper and rasteriser (`TextData::place`) both
/// then work in.** This is what makes glyphs sharp on a dense display:
/// before this existed, `font_size` was already a physical-pixel value
/// (RUST.md's P0 box's global-scale stopgap resolved density by
/// stretching the whole rendered frame afterward instead), so a glyph
/// was rasterised small and then upscaled by whatever the display's
/// scale factor was -- exactly the blur Iris's report described.
/// Multiplying here instead means the font size hitting `ScaleContext`
/// in `place` below is already the display's real physical size, so
/// the atlas holds a bitmap at the resolution it is actually shown at.
/// `GlyphKey.size` already keys on that resolved `font_size`
/// (`(font_size * 16.0).round()`), so a cache entry is naturally per
/// physical size with no change needed there.
pub fn shape(
&mut self,
data: &mut TextData,
attrs: &TextAttrs,
width: Option<f32>,
density: f32,
) {
if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) {
return;
}
// Resolved before the builder borrows `data`: `Family::Icons`
// names an intention, and the name behind it lives on `TextData`.
let base_family = data.resolve_family(&attrs.family);
let span_families: Vec<Option<Family>> = self
.spans
.iter()
.map(|span| span.family.as_ref().map(|f| data.resolve_family(f)))
.collect();
let mut builder = data
.layout_cx
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
builder.push_default(StyleProperty::FontFamily(base_family.family()));
builder.push_default(StyleProperty::FontSize(attrs.font_size * density));
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
attrs.line_height * density,
)));
builder.push_default(StyleProperty::Brush(attrs.color));
for (span, family) in self.spans.iter().zip(&span_families) {
let range = span.range.clone();
if let Some(color) = span.color {
builder.push(StyleProperty::Brush(color), range.clone());
}
if let Some(family) = family {
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
}
if let Some(size) = span.font_size {
builder.push(StyleProperty::FontSize(size * density), range.clone());
}
if span.bold {
builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone());
}
if span.italic {
builder.push(StyleProperty::FontStyle(FontStyle::Italic), range.clone());
}
if span.underline {
builder.push(StyleProperty::Underline(true), range.clone());
}
}
builder.build_into(&mut self.layout, &self.text);
self.layout.break_all_lines(width);
self.layout
.align(Alignment::Start, AlignmentOptions::default());
self.shaped = Some((attrs.clone(), width, density));
}
}
impl TextData { impl TextData {
pub fn draw( /// Rasterise whatever of `buffer` is not in the atlas yet, and return where
/// each glyph goes relative to the text's top-left.
///
/// Nothing is uploaded for a glyph already in the atlas, which is the point
/// of having one: a resize re-runs this and touches the GPU only if the new
/// width brought genuinely new glyphs into view.
pub fn place(&mut self, buffer: &TextBuffer, textures: &mut Textures) -> Vec<PlacedGlyph> {
let mut placed = Vec::new();
for line in buffer.layout.lines() {
for item in line.items() {
let PositionedLayoutItem::GlyphRun(run) = item else {
continue;
};
let font = run.run().font();
let font_size = run.run().font_size();
let coords = run.run().normalized_coords();
let run_color = run.style().brush;
let Some(font_ref) = FontRef::from_index(font.data.as_ref(), font.index as usize)
else {
continue;
};
let coords_hash = hash_coords(coords);
// `font.data.id()` rather than the pointer, so the same font
// loaded twice is still one set of entries.
let font_id = font.data.id();
for glyph in run.positioned_glyphs() {
let subpixel = ((glyph.x.fract() * 4.0).round() as i32).rem_euclid(4) as u8;
let key = GlyphKey {
font: font_id,
glyph: glyph.id,
size: (font_size * 16.0).round() as u32,
subpixel,
coords: coords_hash,
};
let entry = match self.atlas.get(&key) {
Some(entry) => entry,
None => {
let mut scaler = self
.scale_cx
.builder(font_ref)
.size(font_size)
.hint(true)
.normalized_coords(coords)
.build();
let image = Render::new(&[
Source::ColorOutline(0),
Source::ColorBitmap(StrikeWith::BestFit),
Source::Outline,
])
.format(Format::Alpha)
.offset(Vector::new(subpixel as f32 / 4.0, 0.0))
.render(&mut scaler, glyph.id as u16);
match image {
Some(image) => self.atlas.insert(key, &image, textures),
None => {
self.atlas.insert_empty(key);
None
}
}
}
};
let Some(entry) = entry else { continue };
placed.push(PlacedGlyph {
entry,
offset: Vec2::new(
glyph.x.floor() + entry.left as f32,
glyph.y.floor() - entry.top as f32,
),
color: run_color,
});
}
}
}
placed
}
}
fn hash_coords(coords: &[i16]) -> u64 {
// FxHash over the coordinates; they are short and change rarely.
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for c in coords {
h ^= *c as u16 as u64;
h = h.wrapping_mul(0x1000_0000_01b3);
}
h
}
/// A laid-out string, ready to draw: where each glyph goes and how big the
/// whole thing is.
///
/// Cheap to clone and to keep, which is the point -- a widget holds one across
/// frames and re-emits its quads without going near the rasteriser. `color`
/// is the buffer's *base* colour (`TextAttrs::color`) for a caller that wants
/// it as a whole (e.g. tinting a cursor to match); the colour each glyph is
/// actually drawn in is `PlacedGlyph::color`, which a `SpanStyle` can
/// override per range.
#[derive(Clone)]
pub struct RenderedText {
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
pub size: Vec2,
pub color: UiColor,
/// The [`GlyphAtlas::generation`] the glyphs above were placed against.
/// A holder must re-render rather than re-emit these quads once the
/// atlas has moved on (`GlyphAtlas::clear`'s doc says what happens
/// otherwise); `Painter::glyphs` debug-asserts it.
pub generation: u64,
}
impl TextData {
/// Lay out and place in one step, which is what a widget wants.
pub fn render(
&mut self, &mut self,
buffer: &mut TextBuffer, buffer: &mut TextBuffer,
attrs: &TextAttrs, attrs: &TextAttrs,
width: Option<f32>,
textures: &mut Textures, textures: &mut Textures,
density: f32,
) -> RenderedText { ) -> RenderedText {
// TODO: either this or the layout stuff (or both) is super slow, buffer.shape(self, attrs, width, density);
// should probably do texture packing and things if possible. let glyphs = self.place(buffer, textures);
// very visible if you add just a couple of wrapping texts and resize window
// should also be timed to figure out exactly what points need to be sped up
// let mut pixels = HashMap::<_, [u8; 4]>::default();
let mut min_x = 0;
let mut min_y = 0;
let mut max_x = 0;
let mut max_y = 0;
let text_color = {
let c = attrs.color;
cosmic_text::Color::rgba(c.r, c.g, c.b, c.a)
};
let mut max_width = 0.0f32;
let mut height = 0.0;
for run in buffer.layout_runs() {
for glyph in run.glyphs.iter() {
let physical_glyph = glyph.physical((0., 0.), 1.0);
let glyph_color = match glyph.color_opt {
Some(some) => some,
None => text_color,
};
if let Some(img) = self
.swash_cache
.get_image(&mut self.font_system, physical_glyph.cache_key)
{
let mut pos = img.placement;
pos.left += physical_glyph.x;
pos.top = physical_glyph.y + run.line_y as i32 - pos.top;
min_x = min_x.min(pos.left);
min_y = min_y.min(pos.top);
max_x = max_x.max(pos.left + pos.width as i32);
max_y = max_y.max(pos.top + pos.height as i32);
self.glyph_cache
.push((pos, physical_glyph.cache_key, glyph_color));
}
}
max_width = max_width.max(run.line_w);
height += run.line_height;
}
let img_width = (max_x - min_x + 1) as u32;
let img_height = (max_y - min_y + 1) as u32;
let mut image = RgbaImage::new(img_width, img_height);
for (pos, key, color) in self.glyph_cache.drain(..) {
let img = self
.swash_cache
.get_image(&mut self.font_system, key)
.as_ref()
.unwrap();
let mut merge = |i, color: [u8; 4]| {
let i = i as i32;
let x = (i % pos.width as i32 + pos.left - min_x) as u32;
let y = (i / pos.width as i32 + pos.top - min_y) as u32;
let pixel = &mut image[(x, y)].0;
// TODO: no clue if proper alpha blending should be done
*pixel = Simd::from(color).saturating_add(Simd::from(*pixel)).into();
};
match img.content {
SwashContent::Mask => {
for (i, a) in img.data.iter().enumerate() {
let mut color = color.as_rgba();
color[3] = ((color[3] as u32 * *a as u32) / u8::MAX as u32) as u8;
merge(i, color);
}
}
SwashContent::SubpixelMask => todo!("subpixel mask text rendering"),
SwashContent::Color => {
let (colors, _) = img.data.as_chunks::<4>();
for (i, color) in colors.iter().enumerate() {
merge(i, *color);
}
}
}
}
let max_dim = 8192;
if image.width() > max_dim || image.height() > max_dim {
let width = image.width().min(max_dim);
let height = image.height().min(max_dim);
eprintln!(
"WARNING: image of size {:?} cropped to {:?} (texture too big)",
image.dimensions(),
(width, height)
);
image = image.view(0, 0, width, height).to_image();
}
RenderedText { RenderedText {
handle: textures.add(image), glyphs: std::sync::Arc::new(glyphs),
top_left_offset: Vec2::new(min_x as f32, min_y as f32), size: buffer.size(),
size: Vec2::new(max_width, height), color: attrs.color,
generation: self.atlas.generation(),
} }
} }
} }
#[derive(Clone)] #[cfg(test)]
pub struct RenderedText { mod tests {
pub handle: TextureHandle, use super::*;
pub top_left_offset: Vec2, use crate::icon;
pub size: Vec2,
/// Every codepoint `icon` names is actually in the subset the script
/// built. This is the failure `build-icon-font.sh`'s own comment warns
/// about -- a constant added on one side and not the other is a glyph
/// that silently isn't there -- and it is invisible at runtime,
/// because a missing glyph draws as nothing rather than as an error.
#[test]
fn every_icon_is_in_the_bundled_font() {
let font = FontRef::from_index(NERD_ICONS, 0).expect("the bundled icon font parses");
let charmap = font.charmap();
for (name, glyph) in [
("OPEN", icon::OPEN),
("CLOSED", icon::CLOSED),
("COLLAPSE", icon::COLLAPSE),
] {
let mut chars = glyph.chars();
let ch = chars.next().expect("an icon is one character");
assert!(chars.next().is_none(), "{name} is more than one character");
assert_ne!(
charmap.map(ch),
0,
"{name} (U+{:04X}) is not in nerd_icons.ttf -- add it to \
build-icon-font.sh's GLYPHS and rerun the script",
ch as u32
);
}
} }
pub trait HasTextures { /// The font registers, so `Family::Icons` resolves to a real family
fn add_texture(&mut self, image: DynamicImage) -> TextureHandle; /// rather than falling through to sans-serif and drawing tofu.
#[test]
fn the_icon_family_registers_and_resolves() {
let data = TextData::default();
let family = data.resolve_family(&Family::Icons);
assert!(
matches!(family, Family::Named(_)),
"the bundled icon font did not register: {:?}",
data.icon_family
);
}
} }
+293 -34
View File
@@ -1,19 +1,44 @@
use crate::{ use crate::util::{RefCounter, Vec2};
render::TexturePrimitive,
util::{RefCounter, Vec2},
};
use image::{DynamicImage, GenericImageView}; use image::{DynamicImage, GenericImageView};
use std::{ use std::{
collections::HashMap,
ops::Index, ops::Index,
sync::mpsc::{Receiver, Sender, channel}, sync::mpsc::{Receiver, Sender, channel},
}; };
/// Which of the two things a texture slot holds. See TEXTURES.md's
/// "Recommended shape" for why these are drawn so differently: a page is a
/// layer of one shared array texture and never gets its own bind group; a
/// standalone image is the opposite, one texture and one bind group, never a
/// layer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextureKind {
Image,
/// The array-texture layer this page was assigned. Chosen synchronously
/// by `Textures::add_page` rather than by the renderer, because glyph
/// insertion needs it in the same call, before any GPU sync happens.
Page {
layer: u32,
},
}
/// What a [`Textures::shared`] texture is a picture of -- exactly, not by
/// hash: `owner` names the widget kind whose description it is, and `id`
/// packs that description's own fields, so two owners cannot collide and
/// a debugger shows which picture a slot holds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SharedTextureKey {
pub owner: &'static str,
pub id: u64,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TextureHandle { pub struct TextureHandle {
inner: TexturePrimitive, slot: u32,
kind: TextureKind,
size: Vec2, size: Vec2,
counter: RefCounter, counter: RefCounter,
send: Sender<u32>, send: Sender<(TextureKind, u32)>,
} }
/// a texture manager for a ui /// a texture manager for a ui
@@ -21,22 +46,47 @@ pub struct TextureHandle {
pub struct Textures { pub struct Textures {
free: Vec<u32>, free: Vec<u32>,
images: Vec<Option<DynamicImage>>, images: Vec<Option<DynamicImage>>,
/// What each slot is, kept beside the image so a slot can be pushed
/// again without the handle that knows -- see [`Textures::reupload`].
kinds: Vec<TextureKind>,
/// Textures built from a description rather than from a file, one per
/// distinct description: see [`Textures::shared`]. The map holds a
/// reference of its own, so a shared texture outlives every widget
/// drawing it and its slot is never recycled underneath one.
shared: HashMap<SharedTextureKey, TextureHandle>,
/// Next layer to hand out to an atlas page. Pages are never freed (no
/// atlas eviction), so this only grows and `free` never holds one.
next_page_layer: u32,
updates: Vec<Update>, updates: Vec<Update>,
send: Sender<u32>, send: Sender<(TextureKind, u32)>,
recv: Receiver<u32>, recv: Receiver<(TextureKind, u32)>,
} }
pub enum TextureUpdate<'a> { pub enum TextureUpdate<'a> {
Push(&'a DynamicImage), Push(TextureKind, &'a DynamicImage),
Set(u32, &'a DynamicImage), Set(TextureKind, u32, &'a DynamicImage),
/// Overwrite a rectangle of an existing texture, rather than replacing it.
/// The glyph atlas grows a glyph at a time, and re-uploading a whole atlas
/// per glyph is megabytes of copy for a few hundred bytes of change.
/// Only ever issued against a page -- a standalone image is never patched.
Patch(u32, PatchRect, &'a DynamicImage),
Free(u32), Free(u32),
PushFree, PushFree(TextureKind),
SetFree, SetFree,
} }
#[derive(Debug, Clone, Copy)]
pub struct PatchRect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
enum Update { enum Update {
Push(u32), Push(TextureKind, u32),
Set(u32), Set(TextureKind, u32),
Patch(u32, PatchRect),
Free(u32), Free(u32),
} }
@@ -46,58 +96,162 @@ impl Textures {
Self { Self {
free: Vec::new(), free: Vec::new(),
images: Vec::new(), images: Vec::new(),
kinds: Vec::new(),
shared: HashMap::new(),
next_page_layer: 0,
updates: Vec::new(), updates: Vec::new(),
send, send,
recv, recv,
} }
} }
pub fn add(&mut self, image: impl Into<DynamicImage>) -> TextureHandle { pub fn add(&mut self, image: impl Into<DynamicImage>) -> TextureHandle {
let image = image.into(); let image = image.into();
let size = image.dimensions().into(); let size = image.dimensions().into();
let view_idx = self.push(image); let kind = TextureKind::Image;
// 0 == default in renderer; TODO: actually create samplers here let slot = self.push(kind, image);
let sampler_idx = 0;
TextureHandle { TextureHandle {
inner: TexturePrimitive { slot,
view_idx, kind,
sampler_idx,
},
size, size,
counter: RefCounter::new(), counter: RefCounter::new(),
send: self.send.clone(), send: self.send.clone(),
} }
} }
fn push(&mut self, image: DynamicImage) -> u32 { /// Adds a page of the shared glyph atlas array. Only `atlas.rs` should
/// call this -- everything else wants `add`.
pub fn add_page(&mut self, image: impl Into<DynamicImage>) -> TextureHandle {
let image = image.into();
let size = image.dimensions().into();
let layer = self.next_page_layer;
self.next_page_layer += 1;
let kind = TextureKind::Page { layer };
let slot = self.push(kind, image);
TextureHandle {
slot,
kind,
size,
counter: RefCounter::new(),
send: self.send.clone(),
}
}
fn push(&mut self, kind: TextureKind, image: DynamicImage) -> u32 {
if let Some(i) = self.free.pop() { if let Some(i) = self.free.pop() {
self.images[i as usize] = Some(image); self.images[i as usize] = Some(image);
self.updates.push(Update::Set(i)); self.kinds[i as usize] = kind;
self.updates.push(Update::Set(kind, i));
i i
} else { } else {
let i = self.images.len() as u32; let i = self.images.len() as u32;
self.images.push(Some(image)); self.images.push(Some(image));
self.updates.push(Update::Push(i)); self.kinds.push(kind);
self.updates.push(Update::Push(kind, i));
i i
} }
} }
/// The one texture for `key`, building it on the first ask and handing
/// out a further reference to it every time after.
///
/// **Why this exists**: a texture rasterised from a *description* --
/// `widget::mark`'s triangle, from a direction and a colour -- has as
/// many copies as there are widgets asking for it, and each copy is
/// its own GPU texture, its own bind group and its own draw call. A
/// transcript screen with a folded card per tool call built one per
/// card: hundreds of 48x48 textures of three distinct pictures,
/// created and freed again as rows recycled. `make` is not called when
/// the key is already known, so the rasterising is paid once too.
///
/// The map keeps its own reference for the life of the `Textures`, so
/// a shared slot is never freed and never reused for something else --
/// which is what makes a handle held by a long-lived widget safe.
pub fn shared(
&mut self,
key: SharedTextureKey,
make: impl FnOnce() -> DynamicImage,
) -> TextureHandle {
if let Some(handle) = self.shared.get(&key) {
return handle.clone();
}
let handle = self.add(make());
self.shared.insert(key, handle.clone());
handle
}
/// The stored image for a handle, to be written into before `patch`.
pub fn image_mut(&mut self, handle: &TextureHandle) -> &mut DynamicImage {
self.images[handle.slot as usize]
.as_mut()
.expect("texture was freed while still held")
}
/// Queue an upload of just `rect`, after writing it with `image_mut`.
pub fn patch(&mut self, handle: &TextureHandle, rect: PatchRect) {
self.updates.push(Update::Patch(handle.slot, rect));
}
/// Queue every live slot for upload again, in slot order -- what a
/// genuinely new GPU device needs, in place of forgetting everything.
///
/// A new device starts with no textures, and the renderer-side mirror
/// of these slots (`render::texture::GpuTextures`) starts empty with
/// it. What it must not do is start empty while the handles widgets
/// are still holding name slots by *index*: `Textures::reset` used to
/// throw this bookkeeping away, which left every live `TextureHandle`
/// -- one per `widget::mark`, hundreds on a transcript screen --
/// pointing at a slot nothing recognised, and the first frame after an
/// Android surface rebuild panicked in `image_bind_group` ("texture
/// slot 89 is not a live standalone image: None"). Re-uploading
/// instead keeps every index meaning what it meant, because this side
/// still holds the images: the slot list is rebuilt identically,
/// including the empty slots, which go across as `PushFree` so the
/// ones after them still land where they were.
///
/// The glyph atlas comes back with it and is deliberately *not*
/// cleared any more: its pages are slots here, this side holds their
/// pixels, and re-uploading them restores exactly the atlas that was
/// there -- so an app switch no longer costs a re-rasterisation of
/// every glyph on screen either.
///
/// Pending updates are dropped rather than kept: each is either a push
/// or a patch of a slot this replays in full.
pub fn reupload(&mut self) {
self.updates.clear();
self.updates
.extend((0..self.images.len() as u32).map(|i| Update::Push(self.kinds[i as usize], i)));
}
pub fn free(&mut self) { pub fn free(&mut self) {
for idx in self.recv.try_iter() { for (kind, idx) in self.recv.try_iter() {
self.images[idx as usize] = None; self.images[idx as usize] = None;
self.updates.push(Update::Free(idx)); self.updates.push(Update::Free(idx));
// A page's slot is never reclaimed: `GlyphAtlas` never drops the
// handles it holds, and there is no eviction path for a hole in
// the middle of the array's layers. If that ever changes, this
// is where a freed page's layer would need to go on a free list
// of its own, separate from `free`, which only ever holds
// ordinary image slots today.
if kind == TextureKind::Image {
self.free.push(idx); self.free.push(idx);
} }
} }
}
pub fn updates(&mut self) -> impl Iterator<Item = TextureUpdate<'_>> { pub fn updates(&mut self) -> impl Iterator<Item = TextureUpdate<'_>> {
self.updates.drain(..).map(|u| match u { self.updates.drain(..).map(|u| match u {
Update::Push(i) => self.images[i as usize] Update::Push(kind, i) => self.images[i as usize]
.as_ref() .as_ref()
.map(TextureUpdate::Push) .map(|img| TextureUpdate::Push(kind, img))
.unwrap_or(TextureUpdate::PushFree), .unwrap_or(TextureUpdate::PushFree(kind)),
Update::Set(i) => self.images[i as usize] Update::Set(kind, i) => self.images[i as usize]
.as_ref() .as_ref()
.map(|img| TextureUpdate::Set(i, img)) .map(|img| TextureUpdate::Set(kind, i, img))
.unwrap_or(TextureUpdate::SetFree),
Update::Patch(i, rect) => self.images[i as usize]
.as_ref()
.map(|img| TextureUpdate::Patch(i, rect, img))
.unwrap_or(TextureUpdate::SetFree), .unwrap_or(TextureUpdate::SetFree),
Update::Free(i) => TextureUpdate::Free(i), Update::Free(i) => TextureUpdate::Free(i),
}) })
@@ -105,18 +259,36 @@ impl Textures {
} }
impl TextureHandle { impl TextureHandle {
pub fn primitive(&self) -> TexturePrimitive {
self.inner
}
pub fn size(&self) -> Vec2 { pub fn size(&self) -> Vec2 {
self.size self.size
} }
/// The bind-group index this handle draws with. Only valid for a
/// standalone image; an atlas page has no bind group of its own -- it
/// samples the shared array via `layer()` instead. Getting this wrong is
/// a caller bug (the wrong kind of handle reached the wrong draw path),
/// not a recoverable condition, so it panics rather than drawing garbage.
pub fn image_index(&self) -> u32 {
match self.kind {
TextureKind::Image => self.slot,
TextureKind::Page { .. } => panic!("image_index() called on an atlas page handle"),
}
}
/// The layer this page occupies in the shared atlas array texture.
/// Only valid for a page handle; see `image_index`'s note.
pub fn layer(&self) -> u32 {
match self.kind {
TextureKind::Page { layer } => layer,
TextureKind::Image => panic!("layer() called on a standalone image handle"),
}
}
} }
impl Drop for TextureHandle { impl Drop for TextureHandle {
fn drop(&mut self) { fn drop(&mut self) {
if self.counter.drop() { if self.counter.drop() {
let _ = self.send.send(self.inner.view_idx); let _ = self.send.send((self.kind, self.slot));
} }
} }
} }
@@ -125,7 +297,7 @@ impl Index<&TextureHandle> for Textures {
type Output = DynamicImage; type Output = DynamicImage;
fn index(&self, index: &TextureHandle) -> &Self::Output { fn index(&self, index: &TextureHandle) -> &Self::Output {
self.images[index.inner.view_idx as usize].as_ref().unwrap() self.images[index.slot as usize].as_ref().unwrap()
} }
} }
@@ -134,3 +306,90 @@ impl Default for Textures {
Self::new() Self::new()
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use image::RgbaImage;
fn image(n: u32) -> DynamicImage {
RgbaImage::new(n, n).into()
}
fn key(id: u64) -> SharedTextureKey {
SharedTextureKey { owner: "test", id }
}
/// What `widget::mark` needs: one texture per description, however
/// many widgets ask for it, and a different description is a
/// different texture.
#[test]
fn a_shared_texture_is_built_once_and_handed_out_again() {
let mut textures = Textures::new();
let built = std::cell::Cell::new(0);
let make = |textures: &mut Textures, id: u64| {
textures.shared(key(id), || {
built.set(built.get() + 1);
image(4)
})
};
let first = make(&mut textures, 1);
let again = make(&mut textures, 1);
let other = make(&mut textures, 2);
assert_eq!(built.get(), 2, "the second ask for key 1 rasterised again");
assert_eq!(first.image_index(), again.image_index());
assert_ne!(first.image_index(), other.image_index());
}
/// The map's own reference is what keeps a shared slot alive: every
/// widget holding one can go away and the slot must not be recycled,
/// because the next widget to ask gets that same index back.
#[test]
fn a_shared_slot_is_not_freed_when_the_last_widget_drops_it() {
let mut textures = Textures::new();
let slot = textures.shared(key(1), || image(4)).image_index();
textures.free();
let plain = textures.add(image(4));
assert_ne!(
plain.image_index(),
slot,
"an ordinary texture was handed the shared mark's slot"
);
}
/// A new GPU device gets the same slot numbering back, so a handle a
/// widget has been holding all along still names its own texture --
/// the crash `reupload` replaced `reset` to fix.
#[test]
fn reupload_replays_every_slot_in_order_including_the_empty_ones() {
let mut textures = Textures::new();
let keep_a = textures.add(image(4));
let dropped = textures.add(image(4));
let keep_b = textures.add(image(4));
let (a, gone, b) = (
keep_a.image_index(),
dropped.image_index(),
keep_b.image_index(),
);
drop(dropped);
textures.free();
// Drain the updates so far, the way a frame does.
assert!(textures.updates().count() > 0);
textures.reupload();
let kinds: Vec<String> = textures
.updates()
.map(|u| match u {
TextureUpdate::Push(..) => "push".to_string(),
TextureUpdate::PushFree(..) => "push-free".to_string(),
_ => "other".to_string(),
})
.collect();
assert_eq!(
kinds,
["push", "push-free", "push"],
"slots {a}, {gone} (freed) and {b} must replay in order, so the \
indices after a hole still land where they were"
);
}
}
+284
View File
@@ -0,0 +1,284 @@
//! A glyph atlas: one texture holding many rasterised glyphs, so drawing text
//! is a quad per glyph rather than a texture per string.
//!
//! What this replaces is why it exists. Text used to be rasterised into its own
//! `RgbaImage` and uploaded as a whole texture, per text widget, every time
//! anything about it changed -- so every window resize re-rasterised and
//! re-uploaded every visible string, which is what the TODO meant by "resizing
//! (per frame) is really slow". Here a glyph is rasterised once for a given
//! font, size and subpixel offset and then reused by every string that contains
//! it, and a resize re-emits quads without touching the GPU's copy at all.
use crate::{
PatchRect, TextureHandle, Textures, UiColor,
util::{HashMap, Vec2},
};
use image::RgbaImage;
use swash::scale::image::{Content, Image};
/// Side of one atlas page, in pixels. 1024 is 4 MB at RGBA8 -- enough for a
/// few thousand glyphs at UI sizes, and small enough that a page nobody fills
/// is not a big waste. Also the fixed width/height of every layer of the
/// shared array texture in `render::texture` -- `pub(crate)` so that module
/// can size it without a second constant to keep in sync.
pub(crate) const PAGE: u32 = 1024;
/// Transparent margin kept around every glyph, so that sampling one cannot
/// pick up its neighbour along a shared edge.
const PAD: u32 = 1;
/// Identifies a rasterised glyph. Anything that changes the pixels has to be in
/// here, or two different glyphs share one entry and the wrong one is drawn.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct GlyphKey {
pub font: u64,
pub glyph: u32,
/// Font size in 1/16 px, so sizes that round to the same pixels share a
/// raster instead of filling the atlas with near-duplicates.
pub size: u32,
/// Horizontal subpixel phase, in 1/4 px.
pub subpixel: u8,
/// Hash of the variation coordinates; a variable font at two weights is two
/// different sets of pixels from one glyph id.
pub coords: u64,
}
#[derive(Clone, Copy)]
pub struct GlyphEntry {
pub uv_min: [f32; 2],
pub uv_max: [f32; 2],
/// Offset from the glyph's pen position to the top-left of its pixels.
pub left: i32,
pub top: i32,
pub width: u32,
pub height: u32,
pub is_color: bool,
/// The atlas array layer this glyph's page occupies.
pub layer: u32,
}
struct Page {
handle: TextureHandle,
/// Shelf packing: glyphs are placed left to right along a shelf whose
/// height is the tallest glyph on it, and a new shelf starts above when the
/// row runs out. Chosen over a real packer because glyphs at one size are
/// close to the same height, which is the case shelves are good at.
x: u32,
y: u32,
shelf_height: u32,
}
#[derive(Default)]
pub struct GlyphAtlas {
pages: Vec<Page>,
/// Bumped by [`GlyphAtlas::clear`], so anything holding placed glyphs
/// from an earlier atlas can tell that its coordinates are stale --
/// see that method's doc for what goes wrong without it.
generation: u64,
/// `None` for a glyph that rasterised to nothing -- a space, say. Cached
/// too, so it is not re-rasterised on every layout.
entries: HashMap<GlyphKey, Option<GlyphEntry>>,
}
impl GlyphAtlas {
pub fn get(&self, key: &GlyphKey) -> Option<Option<GlyphEntry>> {
self.entries.get(key).copied()
}
/// Rasterised pixels in, a place in the atlas out. `None` means the glyph
/// has no pixels, which is a normal answer rather than a failure.
pub fn insert(
&mut self,
key: GlyphKey,
image: &Image,
textures: &mut Textures,
) -> Option<GlyphEntry> {
let w = image.placement.width;
let h = image.placement.height;
if w == 0 || h == 0 {
self.entries.insert(key, None);
return None;
}
if w + PAD * 2 > PAGE || h + PAD * 2 > PAGE {
// A single glyph larger than a page. Refusing is better than
// silently drawing a cropped one; the caller draws nothing.
self.entries.insert(key, None);
return None;
}
let (page_idx, x, y) = self.allocate(w, h, textures);
let page = &self.pages[page_idx];
let img = textures.image_mut(&page.handle);
let rgba = img.as_mut_rgba8().expect("atlas page is rgba8");
write_glyph(rgba, image, x, y);
let handle = page.handle.clone();
let rect = PatchRect {
x,
y,
width: w,
height: h,
};
textures.patch(&handle, rect);
let page = &self.pages[page_idx];
let scale = 1.0 / PAGE as f32;
let entry = GlyphEntry {
uv_min: [x as f32 * scale, y as f32 * scale],
uv_max: [(x + w) as f32 * scale, (y + h) as f32 * scale],
left: image.placement.left,
top: image.placement.top,
width: w,
height: h,
is_color: matches!(image.content, Content::Color),
layer: page.handle.layer(),
};
self.entries.insert(key, Some(entry));
Some(entry)
}
/// A free `w`x`h` spot, opening a shelf or a page as needed.
fn allocate(&mut self, w: u32, h: u32, textures: &mut Textures) -> (usize, u32, u32) {
let need_w = w + PAD;
let need_h = h + PAD;
if let Some(i) = self.pages.iter().position(|p| fits(p, need_w, need_h)) {
let page = &mut self.pages[i];
if page.x + need_w > PAGE {
page.y += page.shelf_height;
page.x = PAD;
page.shelf_height = 0;
}
let (x, y) = (page.x, page.y);
page.x += need_w;
page.shelf_height = page.shelf_height.max(need_h);
return (i, x, y);
}
let handle = textures.add_page(RgbaImage::new(PAGE, PAGE));
self.pages.push(Page {
handle,
x: PAD + w + PAD,
y: PAD,
shelf_height: h + PAD,
});
(self.pages.len() - 1, PAD, PAD)
}
/// Record that a glyph has no pixels, so it is not re-rasterised.
pub fn insert_empty(&mut self, key: GlyphKey) {
self.entries.insert(key, None);
}
/// Which atlas the entries handed out right now belong to. A
/// [`crate::RenderedText`] records this when it is built and is only
/// reusable while it still matches.
pub fn generation(&self) -> u64 {
self.generation
}
pub fn page_count(&self) -> usize {
self.pages.len()
}
pub fn glyph_count(&self) -> usize {
self.entries.len()
}
/// Forget every page and every rasterised entry -- what a genuinely new
/// GPU device needs (`android::view::IrisViewPeer::surface_changed`'s
/// "not already live" branch, e.g. after backgrounding): the pages this
/// atlas remembers are `TextureHandle`s into the *old* device's
/// textures, which no longer exist, and every `GlyphEntry`'s `uv_min`/
/// `uv_max`/`layer` point into them. Without this, a glyph already
/// cached here is treated as "already placed" and never re-inserted
/// into the fresh (empty) atlas the new renderer actually has --
/// exactly the "rectangles stay, glyphs disappear" bug the resize path
/// (`AndroidRenderer::resize`) was built to avoid for the reuse case;
/// this is its counterpart for the case where the renderer really is
/// new. Dropping `pages` also drops its `TextureHandle`s, which send a
/// free message back through their `Textures`; see `Textures::reset`'s
/// doc for why that is harmless here.
/// Bumping `generation` here is the other half of the same
/// invalidation: emptying this atlas does nothing about the
/// `RenderedText`s widgets are *already holding*
/// (`iris::widget::TextView`'s `tex` cache), whose `PlacedGlyph`s carry
/// `uv_min`/`uv_max`/`layer` into the atlas that has just been thrown
/// away. Those redraw perfectly happily and sample whatever now sits at
/// those coordinates -- the fragments-of-other-glyphs Iris photographed
/// after resuming the app on 2026-09-06. One counter, checked where the
/// cache is read, is what makes a cached render un-reusable across a
/// renderer rebuild.
pub fn clear(&mut self) {
self.pages.clear();
self.entries.clear();
self.generation += 1;
}
}
fn fits(page: &Page, need_w: u32, need_h: u32) -> bool {
// On the current shelf, or on a new one above it.
(page.x + need_w <= PAGE && page.y + need_h <= PAGE)
|| (need_w + PAD <= PAGE && page.y + page.shelf_height + need_h <= PAGE)
}
/// Copy one rasterised glyph into the page image at `(x, y)`.
///
/// A mask glyph keeps its coverage in alpha with the colour left to the shader,
/// so one raster serves text of any colour; a colour glyph carries its own.
fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) {
let w = image.placement.width;
let h = image.placement.height;
match image.content {
Content::Mask => {
for row in 0..h {
for col in 0..w {
let a = image.data[(row * w + col) as usize];
page.put_pixel(x + col, y + row, image::Rgba([255, 255, 255, a]));
}
}
}
Content::Color => {
for row in 0..h {
for col in 0..w {
let i = ((row * w + col) * 4) as usize;
let px = [
image.data[i],
image.data[i + 1],
image.data[i + 2],
image.data[i + 3],
];
page.put_pixel(x + col, y + row, image::Rgba(px));
}
}
}
Content::SubpixelMask => {
// Not asked for: `Format::Alpha` is what the renderer requests, so
// reaching here means the request changed and this needs writing.
// Drawn as a plain mask from the green channel rather than dropped,
// so the text is readable rather than absent.
for row in 0..h {
for col in 0..w {
let i = ((row * w + col) * 4) as usize;
let a = image.data[i + 1];
page.put_pixel(x + col, y + row, image::Rgba([255, 255, 255, a]));
}
}
}
}
}
/// Where a glyph goes on screen, in pixels relative to the text's origin.
///
/// `color` is per-glyph (read from the parley run's own `Brush`, since
/// `UiColor` is parley's brush type here) rather than a single colour for
/// the whole `RenderedText`, so that a span pushed with its own
/// `StyleProperty::Brush` (I5's inline rich text: a link, a diff of colour
/// inside one wrapped paragraph) actually renders in that colour instead of
/// the buffer's base one.
#[derive(Clone, Copy)]
pub struct PlacedGlyph {
pub entry: GlyphEntry,
pub offset: Vec2,
pub color: UiColor,
}
+98 -16
View File
@@ -8,6 +8,15 @@ pub struct WindowUniform {
pub height: f32, pub height: f32,
} }
/// One primitive's placement and what to draw there, in the one arena
/// every layer shares (`Primitives`). Read from a storage buffer by
/// **both** shader stages: the vertex stage for the corners of the
/// primitive it is drawing, the fragment stage for the corners of a
/// *mask's* primitive, which is generally a different one and often in
/// another layer. A layer's vertex buffer carries only the slot
/// ([`instance_slot_layout`]), so there is exactly one copy of a
/// placement and a mask cannot disagree with what was drawn. See
/// LAYOUT.md's "Masks with a shape".
#[repr(C)] #[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct PrimitiveInstance { pub struct PrimitiveInstance {
@@ -15,25 +24,20 @@ pub struct PrimitiveInstance {
pub binding: u32, pub binding: u32,
pub idx: u32, pub idx: u32,
pub mask_idx: MaskIdx, pub mask_idx: MaskIdx,
pub move_idx: MoveIdx,
} }
impl PrimitiveInstance { /// The vertex layout of a layer's draw order: one `u32` slot into the
const ATTRIBS: [VertexAttribute; 7] = vertex_attr_array![ /// global instance arena per instance, stepped per instance. Everything a
0 => Float32x2, /// primitive is made of used to be here as eight vertex attributes; it
1 => Float32x2, /// moved into the storage buffer above so the fragment stage can read it
2 => Float32x2, /// too.
3 => Float32x2, pub fn instance_slot_layout() -> VertexBufferLayout<'static> {
4 => Uint32, const ATTRIBS: [VertexAttribute; 1] = vertex_attr_array![0 => Uint32];
5 => Uint32,
6 => Uint32,
];
pub fn desc() -> VertexBufferLayout<'static> {
VertexBufferLayout { VertexBufferLayout {
array_stride: std::mem::size_of::<Self>() as BufferAddress, array_stride: std::mem::size_of::<u32>() as BufferAddress,
step_mode: VertexStepMode::Instance, step_mode: VertexStepMode::Instance,
attributes: &Self::ATTRIBS, attributes: &ATTRIBS,
}
} }
} }
@@ -43,8 +47,86 @@ impl MaskIdx {
pub const NONE: Self = Self::preset(u32::MAX); pub const NONE: Self = Self::preset(u32::MAX);
} }
pub type MoveIdx = Id<u32>;
/// A clip, as a reference to a primitive already written plus the mask it
/// nests inside. The fragment stage evaluates that primitive's coverage
/// *at the masked pixel* -- for a rect, the same `rounded_rect_coverage`
/// from the same SDF the rect itself is drawn with -- and multiplies it
/// into the pixel's alpha, so a rounded container's corner and its
/// children's clipped corner are the same arithmetic and cannot disagree.
/// See LAYOUT.md's "Masks with a shape".
///
/// **No `kind` and no `flags`**, which the design sketched: the referenced
/// instance already carries its own `binding`, and a copy of it here is a
/// second thing to keep in step; alpha-only is the only mode there is, so
/// there is nothing to select. Both are a field away if a second mode
/// appears.
#[repr(C)] #[repr(C)]
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] #[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Mask { pub struct Mask {
pub region: UiRegion, /// The slot in `UiRenderState::primitives` of the primitive whose
/// coverage this mask is. Today always a `RectPrimitive`: a glyph or
/// a standalone image would need, respectively, a CPU-side alpha
/// plane for the hit test to agree with the shader, and a bind-group
/// switch the fragment stage cannot make -- `Painter::set_mask`
/// rejects both by name rather than leaving the shader to read a rect
/// that is not there.
///
/// Who owns it depends on which way the mask was set. A plain
/// `.masked()` writes its own undrawn rect, so the primitive is in
/// the masking widget's `ActiveData::primitives` and lives exactly as
/// long as the mask. `.masked_by(shape)` points at a *child's*
/// primitive, which that child can free on any redraw of its own --
/// so `UiRenderState::remask_shape_users` marks the mask's owner for
/// redraw whenever a referenced slot is freed, since that widget's
/// own `set_mask` is the only thing that resolves the slot again.
pub primitive: u32,
/// The mask this one was set *inside* (`MaskIdx::NONE` at the top), so
/// clipping nests: the fragment stage walks the chain and multiplies
/// every coverage on it, which is what makes a pixel inside two
/// feathered corners dimmed by both. Chained rather than intersected
/// on the CPU because each mask moves with its own widget -- a code
/// fence inside a transcript row carries the row's scroll, the list's
/// own box does not, and one region resolved when the fence was last
/// drawn gets the second of those wrong as soon as the row moves.
///
/// A child holds one ref on its parent's slot (`Painter::set_mask`),
/// released when the child's own slot goes
/// (`UiRenderState::remove`), so the chain cannot outlive what it
/// points at.
pub parent: MaskIdx,
}
/// One widget's cumulative on-screen translation, and the slot of the
/// ancestor to add on top of it. `parent == u32::MAX` ends the chain. A
/// pure abs-pixel delta, not a general `UiRegion` remap -- sufficient for
/// every call site that moves a widget (`ScrollArea`, `Offset`) since both are
/// translations of an already-drawn subtree. See LAYOUT.md section 2.
///
/// `_pad` matches WGSL's storage-buffer layout for `MoveOffset`: `delta` is
/// a `vec2<f32>`, which gives the struct an 8-byte alignment and rounds its
/// WGSL size up to 16 bytes even though `delta` + `parent` only total 12 --
/// the same trap `GlyphPrimitive` documents below. `bytemuck` does not
/// check this for us, and getting it wrong is a wgpu validation panic at
/// draw time ("buffer bound ... with size 12 where the shader expects 16"),
/// not a compile error.
#[repr(C)]
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct MoveOffset {
pub delta: [f32; 2],
pub parent: u32,
_pad: u32,
}
impl MoveOffset {
pub const NONE_PARENT: u32 = u32::MAX;
pub fn new(delta: [f32; 2], parent: u32) -> Self {
Self {
delta,
parent,
_pad: 0,
}
}
} }
+557
View File
@@ -0,0 +1,557 @@
use std::time::{Duration, Instant};
/// The frame budget `dumpsys gfxinfo` also uses to call a frame "janky": the
/// 60Hz vsync period. Kept as the same threshold so a percentage from this
/// report and a percentage from `gfxinfo` mean the same thing. Only a
/// fallback now that a caller can read the display's real refresh rate
/// (`report_at_hz`/`mark_phase`'s callers) -- most devices are 60Hz, but a
/// 90Hz or 120Hz phone judged against this constant would call every frame
/// "late" that merely met its own, faster budget.
pub const JANK_THRESHOLD: Duration = Duration::from_nanos(16_666_667);
/// Enough frames for several minutes of scrolling before the oldest ones
/// start being overwritten -- the same "diagnostic, not a log" sizing
/// `FrameStats.kt`'s `CAP` uses on the Compose side, chosen independently
/// here since a `Duration` is smaller than the six `Long` arrays it keeps.
/// Bumped from 4096 for RUST.md's "Benchmark v2": a fling+stream+type+
/// keyboard run is ~6,500+ frames on the Compose side, comfortably under
/// this so `phase_stats` never has to report a phase as partially evicted.
const RING_CAPACITY: usize = 16384;
/// One `mark_phase` call: the wall-clock instant and the (0-based,
/// never-reset-by-`reset`-except-at-`reset`-time) absolute frame index at
/// which a phase began -- `phase_stats` slices `index_ring` against this to
/// find which recorded samples belong to which phase, since the ring
/// itself only keeps the most recent `RING_CAPACITY` samples' *values*,
/// not which phase they were in.
struct PhaseMark {
name: String,
start_index: u64,
start_at: Instant,
}
/// One phase's own slice of a report -- RUST.md's "Benchmark v2" spec's
/// "per-phase blocks in `FrameReport`... frames, late count/percent...
/// p50/p90/p99, worst, duration". `Display` matches the shape
/// `docs/bench/compose-phone-v2-2026-09-06.md`'s report already uses, so
/// the two apps' reports read the same way side by side.
pub struct PhaseStats {
pub name: String,
/// How many frames were recorded during this phase in total -- may
/// exceed `late + (samples counted)` if some of this phase's frames
/// have since been evicted from the ring by a very long run; that
/// case is named in the `Display` rather than silently under-counted.
pub frames: u64,
pub duration: Duration,
pub late: u64,
pub late_percent: f64,
pub p50: Duration,
pub p90: Duration,
pub p99: Duration,
pub worst: Duration,
/// `false` if this phase's frame count exceeds how many samples of it
/// are still in the ring -- the percentiles above are then computed
/// over whatever survived, not the whole phase. UI_RULES.md: this is
/// the "we don't fully know" state, named rather than folded silently
/// into a number that looks exact.
pub complete: bool,
}
impl std::fmt::Display for PhaseStats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(
f,
" {}: {} frames over {:.1}s{}",
self.name,
self.frames,
self.duration.as_secs_f64(),
if self.complete {
""
} else {
" (ring evicted some of this phase)"
},
)?;
writeln!(f, " late: {} ({:.1}%)", self.late, self.late_percent)?;
writeln!(
f,
" total p50 {:.1}ms p90 {:.1}ms p99 {:.1}ms",
self.p50.as_secs_f64() * 1000.0,
self.p90.as_secs_f64() * 1000.0,
self.p99.as_secs_f64() * 1000.0,
)?;
write!(f, " worst {:.1}ms", self.worst.as_secs_f64() * 1000.0)
}
}
/// A per-frame wall-time report iris keeps of itself, because `dumpsys
/// gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all
/// (RUST.md's I5 box, "Measurements taken" (b)): it instruments Android's
/// ordinary Skia/HWUI View-drawing pipeline, which a `wgpu`-rendered
/// `SurfaceView` bypasses entirely. `record` is meant to be called once per
/// frame, wrapping the same span Compose's own render report and `gfxinfo`
/// count -- from the frame's redraw/update start to after the frame is
/// handed to the platform to present.
///
/// **What this does not measure**: wgpu's `present()` call queues the frame
/// with the compositor and returns; it is not fenced against the GPU
/// actually finishing the frame or the compositor actually showing it, the
/// way `gfxinfo`'s own `GPU_DURATION`/vsync accounting is. So a sample here
/// is "how long the CPU took to build and submit this frame", not
/// "how long the frame took to reach the screen" -- named in
/// [`FrameStats`]'s own `Display` line rather than presented as the latter,
/// per the standing rule against showing an inferred number as a measured
/// one where the two differ.
///
/// Fixed-size ring, no allocation on the hot path -- `report()` is the only
/// place that allocates (a sort over the current ring), and it is only
/// ever called from a button tap, not once per frame.
pub struct FrameReport {
ring: Box<[Duration; RING_CAPACITY]>,
/// The `submit_to_present` half of each sample in `ring`, same index,
/// same lifetime -- kept as a second ring rather than a ring of pairs so
/// the existing `ring`/percentile code above is untouched (RUST.md's I5
/// "Where iris's frame time goes" CPU/GPU split, added 2026-09-05).
/// `ring[i] - submit_ring[i]` is that frame's `redraw_to_submit` half.
submit_ring: Box<[Duration; RING_CAPACITY]>,
/// The absolute (0-based, since the last `reset`) frame index each
/// `ring`/`submit_ring` slot's sample belongs to -- what `phase_stats`
/// slices against `PhaseMark::start_index` to tell which recorded
/// frames fall in which phase.
index_ring: Box<[u64; RING_CAPACITY]>,
/// How many of `ring`'s slots hold a real sample -- saturates at
/// `RING_CAPACITY`, unlike `total_frames` below which keeps counting.
len: usize,
pos: usize,
/// All frames recorded since the last `reset`, even past `RING_CAPACITY`
/// -- what `janky_percent` divides by, so a long run's percentage stays
/// correct even once the ring itself only holds the most recent frames.
total_frames: u64,
janky_frames: u64,
/// `mark_phase` calls since the last `reset`, oldest first -- see
/// `phase_stats`. Empty on an ordinary run that never calls
/// `mark_phase`, so `phase_stats` returns an empty `Vec` and a caller
/// prints no "per phase:" section at all, matching RUST.md's "empty/
/// absent on an ordinary 'Copy' press, which never marks a phase."
phases: Vec<PhaseMark>,
}
/// One resolved reading. `Display` is the log line both the "Frame report"
/// button and `transcript-bench.sh`-style scripts read, grep-able on
/// `"iris frame report"`.
pub struct FrameStats {
pub total_frames: u64,
pub janky_percent: f64,
pub p50: Duration,
pub p90: Duration,
pub p99: Duration,
pub worst: Duration,
/// Median of `redraw_to_submit` -- iris's own CPU work (layout, text,
/// primitive building) up to and including building the `queue.submit`
/// call, per frame. RUST.md's I5 "Where iris's frame time goes" split,
/// added 2026-09-05 to answer "CPU or GPU?" with a number rather than a
/// guess.
pub cpu_p50: Duration,
/// Median of `submit_to_present` -- the `queue.submit` call itself plus
/// `present()`, i.e. wherever the driver/GPU/compositor wait actually
/// happens. Same caveat as the type's own doc: `present()` is not
/// fenced against the GPU actually finishing, so this is "how long the
/// CPU was blocked handing the frame off", not the frame's true GPU
/// time -- still enough to separate "iris is slow building the frame"
/// from "iris is slow handing it to the driver".
pub gpu_wait_p50: Duration,
}
impl std::fmt::Display for FrameStats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"frames={} janky%={:.2} p50={:.1}ms p90={:.1}ms p99={:.1}ms worst={:.1}ms \
(measures redraw-start to after present() is called, not GPU/compositor \
completion)",
self.total_frames,
self.janky_percent,
self.p50.as_secs_f64() * 1000.0,
self.p90.as_secs_f64() * 1000.0,
self.p99.as_secs_f64() * 1000.0,
self.worst.as_secs_f64() * 1000.0,
)?;
write!(
f,
" cpu_p50={:.1}ms gpu_wait_p50={:.1}ms (redraw-start-to-submit vs. \
submit-to-after-present)",
self.cpu_p50.as_secs_f64() * 1000.0,
self.gpu_wait_p50.as_secs_f64() * 1000.0,
)
}
}
impl FrameReport {
pub fn new() -> Self {
Self {
ring: Box::new([Duration::ZERO; RING_CAPACITY]),
submit_ring: Box::new([Duration::ZERO; RING_CAPACITY]),
index_ring: Box::new([0; RING_CAPACITY]),
len: 0,
pos: 0,
total_frames: 0,
janky_frames: 0,
phases: Vec::new(),
}
}
/// Record one frame's elapsed wall time, with no CPU/GPU split (the
/// `submit_to_present` half is recorded as zero, so `cpu_p50` reads as
/// the whole frame and `gpu_wait_p50` as nothing -- honest for a caller
/// that never measured the split, rather than fabricating one). O(1),
/// no allocation.
pub fn record(&mut self, elapsed: Duration) {
self.record_split(elapsed, Duration::ZERO);
}
/// Record one frame's elapsed wall time, split at `queue.submit`:
/// `submit_to_present` is the `queue.submit()` call plus `present()`;
/// `total - submit_to_present` is everything before it (layout, text,
/// primitive building). RUST.md's I5 "Where iris's frame time goes"
/// CPU/GPU split, added 2026-09-05. O(1), no allocation.
pub fn record_split(&mut self, total: Duration, submit_to_present: Duration) {
self.ring[self.pos] = total;
self.submit_ring[self.pos] = submit_to_present;
self.index_ring[self.pos] = self.total_frames;
self.pos = (self.pos + 1) % RING_CAPACITY;
self.len = (self.len + 1).min(RING_CAPACITY);
self.total_frames += 1;
if total > JANK_THRESHOLD {
self.janky_frames += 1;
}
}
/// Clears every counter and every sample -- what the "Reset frame
/// report" control calls, so a report covers only what was scrolled
/// after the button was pressed (the same reason `FrameStats.kt`'s
/// `reset()` exists on the Compose side). Also clears every phase
/// mark, so a fresh run starts with no "per phase:" section until it
/// marks one of its own.
pub fn reset(&mut self) {
self.len = 0;
self.pos = 0;
self.total_frames = 0;
self.janky_frames = 0;
self.phases.clear();
}
/// Marks the start of a named phase at the current moment -- every
/// frame recorded from here until the next `mark_phase` (or `reset`)
/// belongs to it. RUST.md's "Benchmark v2": a scripted bench run calls
/// this once per phase (fling/stream/type/keyboard) so `phase_stats`
/// can slice one whole run's frames by what was happening during each.
pub fn mark_phase(&mut self, name: &str) {
// `phase_stats`'s slicing (`idx >= phase.start_index && idx <
// end_index`) silently produces an empty or nonsensical slice for
// a phase pushed out of order rather than surfacing the misuse
// (docs/REVIEW-2026-09-06.md finding 5).
debug_assert!(
self.phases
.last()
.is_none_or(|p| self.total_frames >= p.start_index)
);
self.phases.push(PhaseMark {
name: name.to_string(),
start_index: self.total_frames,
start_at: Instant::now(),
});
}
/// One [`PhaseStats`] per `mark_phase` call since the last `reset`,
/// oldest first. `now` closes the last phase's wall-clock span (there
/// is no "next phase" instant to use for it); `refresh_hz` is what
/// each phase's own `late`/`late_percent` is judged against, read from
/// the display rather than assumed -- RUST.md's "Benchmark v2": "late
/// count/% against the display's refresh rate."
pub fn phase_stats(&self, now: Instant, refresh_hz: f32) -> Vec<PhaseStats> {
if self.phases.is_empty() || refresh_hz <= 0.0 {
return Vec::new();
}
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
self.phases
.iter()
.enumerate()
.map(|(i, phase)| {
let (end_index, end_at) = match self.phases.get(i + 1) {
Some(next) => (next.start_index, next.start_at),
None => (self.total_frames, now),
};
let frames = end_index.saturating_sub(phase.start_index);
let mut samples: Vec<Duration> = (0..self.len)
.filter(|&j| {
let idx = self.index_ring[j];
idx >= phase.start_index && idx < end_index
})
.map(|j| self.ring[j])
.collect();
let complete = samples.len() as u64 >= frames;
if samples.is_empty() {
return PhaseStats {
name: phase.name.clone(),
frames,
duration: end_at.saturating_duration_since(phase.start_at),
late: 0,
late_percent: 0.0,
p50: Duration::ZERO,
p90: Duration::ZERO,
p99: Duration::ZERO,
worst: Duration::ZERO,
complete,
};
}
samples.sort_unstable();
let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)];
let late = samples.iter().filter(|&&d| d > budget).count() as u64;
PhaseStats {
name: phase.name.clone(),
frames,
duration: end_at.saturating_duration_since(phase.start_at),
late,
late_percent: 100.0 * late as f64 / samples.len() as f64,
p50: pct(50),
p90: pct(90),
p99: pct(99),
worst: *samples.last().expect("checked not empty above"),
complete,
}
})
.collect()
}
/// `None` if nothing has been recorded since the last reset -- the
/// "no frames recorded, scroll first" case, not a zeroed report that
/// would read as a real (perfect) measurement.
pub fn report(&self) -> Option<FrameStats> {
if self.len == 0 {
return None;
}
let mut samples: Vec<Duration> = self.ring[..self.len].to_vec();
samples.sort_unstable();
let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)];
// Separate arrays rather than subtracting the two medians above:
// medians do not distribute over subtraction, and each needs its
// own sort.
let submit_samples: Vec<Duration> = self.submit_ring[..self.len].to_vec();
let cpu_samples: Vec<Duration> = self.ring[..self.len]
.iter()
.zip(self.submit_ring[..self.len].iter())
.map(|(&total, &submit_to_present)| total.saturating_sub(submit_to_present))
.collect();
let median = |mut v: Vec<Duration>| {
v.sort_unstable();
v[v.len() / 2]
};
Some(FrameStats {
total_frames: self.total_frames,
janky_percent: 100.0 * self.janky_frames as f64 / self.total_frames as f64,
p50: pct(50),
p90: pct(90),
p99: pct(99),
worst: *samples.last().expect("len > 0 checked above"),
cpu_p50: median(cpu_samples),
gpu_wait_p50: median(submit_samples),
})
}
/// `(late count, late percent)` over every sample still in the ring,
/// judged against `refresh_hz`'s own frame budget rather than the
/// fixed 60Hz `JANK_THRESHOLD` -- RUST.md's "Benchmark v2": "late
/// count/% against the display's refresh rate... print 'at N Hz (X ms
/// budget)' like Compose does." A separate method from `report()`
/// rather than a parameter on it, so `report()`'s own `janky_percent`
/// (and the exact-boundary test pinned to `JANK_THRESHOLD`) is
/// unaffected for every existing caller that never measured a real
/// refresh rate. `(0, 0.0)` with nothing recorded or a non-positive
/// `refresh_hz`.
pub fn late_at_hz(&self, refresh_hz: f32) -> (u64, f64) {
if self.len == 0 || refresh_hz <= 0.0 {
return (0, 0.0);
}
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
let late = self.ring[..self.len]
.iter()
.filter(|&&d| d > budget)
.count() as u64;
(late, 100.0 * late as f64 / self.len as f64)
}
}
impl Default for FrameReport {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_frames_reports_none() {
assert!(FrameReport::new().report().is_none());
}
#[test]
fn one_frame_is_every_percentile_and_the_worst() {
let mut r = FrameReport::new();
r.record(Duration::from_millis(10));
let stats = r.report().unwrap();
assert_eq!(stats.total_frames, 1);
assert_eq!(stats.p50, Duration::from_millis(10));
assert_eq!(stats.p99, Duration::from_millis(10));
assert_eq!(stats.worst, Duration::from_millis(10));
assert_eq!(stats.janky_percent, 0.0);
}
#[test]
fn percentiles_and_worst_over_a_known_set() {
let mut r = FrameReport::new();
// 100 samples, 1ms..=100ms, fed out of order so the ring's own
// order is not what gives the right answer -- the sort has to.
for ms in (1..=100).rev() {
r.record(Duration::from_millis(ms));
}
let stats = r.report().unwrap();
assert_eq!(stats.total_frames, 100);
assert_eq!(stats.p50, Duration::from_millis(51));
assert_eq!(stats.p90, Duration::from_millis(91));
assert_eq!(stats.p99, Duration::from_millis(100));
assert_eq!(stats.worst, Duration::from_millis(100));
}
#[test]
fn jank_threshold_matches_gfxinfos_60hz_budget() {
let mut r = FrameReport::new();
r.record(Duration::from_nanos(16_666_667)); // exactly on budget: not janky
r.record(Duration::from_nanos(16_666_668)); // one ns over: janky
let stats = r.report().unwrap();
assert_eq!(stats.janky_percent, 50.0);
}
#[test]
fn janky_percent_is_over_all_time_frames_not_just_the_ring() {
// Fewer than RING_CAPACITY frames, all janky, then a fresh reset --
// the percentage must reset to 0, not divide by a stale count.
let mut r = FrameReport::new();
for _ in 0..10 {
r.record(Duration::from_millis(50));
}
assert_eq!(r.report().unwrap().janky_percent, 100.0);
r.reset();
assert!(r.report().is_none());
r.record(Duration::from_millis(1));
assert_eq!(r.report().unwrap().janky_percent, 0.0);
}
#[test]
fn record_without_a_split_reports_the_whole_frame_as_cpu() {
// A caller that never measured the split (plain `record`) should
// not fabricate a GPU-wait number -- it reads as zero, and the CPU
// half reads as the whole frame.
let mut r = FrameReport::new();
r.record(Duration::from_millis(20));
let stats = r.report().unwrap();
assert_eq!(stats.cpu_p50, Duration::from_millis(20));
assert_eq!(stats.gpu_wait_p50, Duration::ZERO);
}
#[test]
fn record_split_reports_each_halfs_own_median() {
let mut r = FrameReport::new();
// Three frames: total is always 30ms, but the CPU/GPU-wait split
// moves, so the two medians must be independent of each other and
// of `total`'s own median.
r.record_split(Duration::from_millis(30), Duration::from_millis(5));
r.record_split(Duration::from_millis(30), Duration::from_millis(10));
r.record_split(Duration::from_millis(30), Duration::from_millis(20));
let stats = r.report().unwrap();
assert_eq!(stats.p50, Duration::from_millis(30));
assert_eq!(stats.gpu_wait_p50, Duration::from_millis(10));
assert_eq!(stats.cpu_p50, Duration::from_millis(20));
}
#[test]
fn ring_wraps_without_growing_past_capacity() {
let mut r = FrameReport::new();
for i in 0..(RING_CAPACITY * 2) {
r.record(Duration::from_millis(1 + (i % 5) as u64));
}
let stats = r.report().unwrap();
// total_frames keeps the full count even once the ring has wrapped.
assert_eq!(stats.total_frames, (RING_CAPACITY * 2) as u64);
// but every sample the ring can report on is still one of the five
// values fed in, since a wrap can only overwrite with more of the
// same pattern here.
assert!(stats.worst <= Duration::from_millis(5));
}
#[test]
fn no_marks_means_no_phases() {
let mut r = FrameReport::new();
r.record(Duration::from_millis(5));
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
}
#[test]
fn phases_slice_frames_by_when_they_were_marked() {
let mut r = FrameReport::new();
r.mark_phase("a");
for _ in 0..5 {
r.record(Duration::from_millis(10)); // 10ms: late at 60Hz (16.7ms budget)... no, 10<16.7, not late
}
r.mark_phase("b");
for _ in 0..3 {
r.record(Duration::from_millis(20)); // 20ms: late at 60Hz
}
let now = Instant::now();
let phases = r.phase_stats(now, 60.0);
assert_eq!(phases.len(), 2);
assert_eq!(phases[0].name, "a");
assert_eq!(phases[0].frames, 5);
assert_eq!(phases[0].late, 0);
assert_eq!(phases[0].worst, Duration::from_millis(10));
assert_eq!(phases[1].name, "b");
assert_eq!(phases[1].frames, 3);
assert_eq!(phases[1].late, 3);
assert_eq!(phases[1].late_percent, 100.0);
assert_eq!(phases[1].worst, Duration::from_millis(20));
assert!(phases[0].complete);
assert!(phases[1].complete);
}
#[test]
fn the_last_phase_runs_until_now() {
let mut r = FrameReport::new();
r.mark_phase("only");
r.record(Duration::from_millis(1));
std::thread::sleep(Duration::from_millis(20));
let now = Instant::now();
let phases = r.phase_stats(now, 60.0);
assert_eq!(phases.len(), 1);
assert!(phases[0].duration >= Duration::from_millis(20));
}
#[test]
fn reset_clears_phase_marks() {
let mut r = FrameReport::new();
r.mark_phase("a");
r.record(Duration::from_millis(1));
r.reset();
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
}
#[test]
fn late_at_hz_uses_the_given_refresh_rate_not_the_fixed_60hz_constant() {
let mut r = FrameReport::new();
// 10ms is under 60Hz's 16.7ms budget but over 120Hz's 8.3ms one.
r.record(Duration::from_millis(10));
assert_eq!(r.late_at_hz(60.0), (0, 0.0));
assert_eq!(r.late_at_hz(120.0), (1, 100.0));
}
}
+458 -101
View File
@@ -1,30 +1,141 @@
use std::num::NonZero;
use crate::{ use crate::{
UiData, UiRenderState, UiData, UiRenderState,
render::{data::PrimitiveInstance, texture::GpuTextures, util::ArrBuf}, render::{
util::HashMap, data::{PrimitiveInstance, instance_slot_layout},
texture::GpuTextures,
util::ArrBuf,
},
util::{HashMap, Vec2},
}; };
use data::WindowUniform; use data::WindowUniform;
use pollster::FutureExt;
use wgpu::{ use wgpu::{
util::{BufferInitDescriptor, DeviceExt}, util::{BufferInitDescriptor, DeviceExt},
*, *,
}; };
use winit::dpi::PhysicalSize;
mod atlas;
mod data; mod data;
mod frame_report;
mod primitive; mod primitive;
mod sdf;
mod texture; mod texture;
mod util; mod util;
pub use data::{Mask, MaskIdx}; pub use atlas::*;
pub use data::{Mask, MaskIdx, MoveIdx, MoveOffset};
pub use frame_report::{FrameReport, FrameStats, JANK_THRESHOLD};
pub use primitive::*; pub use primitive::*;
pub use sdf::{distance_from_rect, rounded_rect_coverage};
const SHAPE_SHADER: &str = include_str!("./shader.wgsl"); /// The one shader every primitive is drawn with. Public so a test can run
/// a function out of it against the CPU transliteration in [`sdf`] --
/// `iris/tests/mask_sdf.rs`, which LAYOUT.md's "Masks with a shape" turns
/// on: a masked corner that cannot be tapped and a masked corner that is
/// not drawn are only the same corner while the two agree.
pub const SHAPE_SHADER: &str = include_str!("./shader.wgsl");
/// The `wgpu::Limits` both platform backends (`android::render::
/// AndroidRenderer::new`, `default::render::UiRenderer::new`) ask
/// `Adapter::request_device` for -- shared so the two copies cannot drift,
/// per AGENTS.md's "write the logic once."
///
/// Built from `Limits::default()`, **not** a downlevel variant: the shader
/// (`shader.wgsl`) reads four `var<storage>` buffers (rects, glyphs, masks,
/// move_offsets) from the vertex stage, and `Limits::downlevel_webgl2_defaults()`
/// zeroes `max_storage_buffers_per_shader_stage` along with the compute
/// limits below -- switching to it would trade one `request_device` crash
/// for a bind-group-layout one on the same downlevel hardware this is meant
/// to support. `max_buffer_size` is raised for the growing instance/atlas
/// buffers (`ArrBuf`, `GpuTextures`); everything else is `default()`'s
/// desktop-tier value, unchanged.
///
/// The six `max_compute_*` fields are zeroed because nothing in this crate
/// creates a `ComputePipeline` or writes a `@compute` shader stage --
/// grepped for both across `iris`/`iris-core` before writing this, found
/// none. `Limits::default()` requests desktop-tier compute limits
/// unconditionally (`max_compute_workgroups_per_dimension: 65535`) even
/// though nothing asks a device to actually support compute, which is what
/// crashed `request_device` on the Android emulator's software GL path
/// (`EMU_GPU=software`, `force-gles`): SwiftShader's GL reports itself as
/// OpenGL ES 3.0, which has no compute shaders, so the adapter's real limit
/// is 0 and the unconditional request fails outright
/// (`RUST.md`'s "Software mode ... crashes for a third, different reason").
/// The same would happen on a real GLES-3.0-only Android device. If a
/// future change adds a compute pass, request the specific limits it needs
/// here rather than reverting to the desktop-tier default for everything.
pub fn device_limits() -> Limits {
Limits {
max_buffer_size: 1 << 30,
max_compute_workgroup_storage_size: 0,
max_compute_invocations_per_workgroup: 0,
max_compute_workgroup_size_x: 0,
max_compute_workgroup_size_y: 0,
max_compute_workgroup_size_z: 0,
max_compute_workgroups_per_dimension: 0,
..Default::default()
}
}
/// A capped log of wgpu's *uncaptured* errors -- everything that reaches
/// `Device::on_uncaptured_error` rather than one of `UiRenderNode::new`'s
/// own error scopes, i.e. every wgpu error raised outside device/pipeline
/// creation: a validation failure during an ordinary frame's `update`/
/// `draw`, for instance. wgpu's default handler for these is `panic!` with
/// no caller able to intervene -- exactly what aborted the P0 bench APK
/// once already (this file's `UiRenderNode::new` doc comment) -- so both
/// platform backends install a handler here instead of leaving the default
/// in place, per RUST.md's P0 box ("every wgpu uncaptured error ... it
/// must never panic in release").
///
/// Cheap to `Clone` (an `Arc` around the real storage) rather than a
/// process-wide static, so a caller builds one alongside its `Device`,
/// hands one clone to `on_uncaptured_error`'s closure and keeps the other
/// for the Diagnostics page to read -- context passed explicitly, per
/// AGENTS.md/CODE_RULES.md's "no globals" rather than reached for through a
/// `OnceLock`.
#[derive(Clone)]
pub struct WgpuErrorLog {
errors: std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<String>>>,
}
/// How many uncaptured errors the log keeps -- old ones drop off the front
/// rather than being trimmed on read, so a build spraying errors every
/// frame doesn't grow this without bound.
const WGPU_ERROR_LOG_CAP: usize = 20;
impl Default for WgpuErrorLog {
fn default() -> Self {
Self {
errors: std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
}
}
}
impl WgpuErrorLog {
pub fn record(&self, error: impl std::fmt::Display) {
let mut errors = self.errors.lock().unwrap();
if errors.len() >= WGPU_ERROR_LOG_CAP {
errors.pop_front();
}
errors.push_back(error.to_string());
}
/// A snapshot for the Diagnostics page -- cloned rather than held,
/// since the lock must not outlive one call.
pub fn snapshot(&self) -> Vec<String> {
self.errors.lock().unwrap().iter().cloned().collect()
}
}
pub struct UiRenderNode { pub struct UiRenderNode {
uniform_group: BindGroup, uniform_group: BindGroup,
primitive_layout: BindGroupLayout, primitive_layout: BindGroupLayout,
/// Group 1: `rects` and `glyphs`. Global and bound once per frame,
/// not per layer -- a mask referencing a rect drawn in another layer
/// has to be able to read it (see `Primitives`).
primitives: PrimitiveBuffers,
primitive_group: BindGroup,
rsc_layout: BindGroupLayout, rsc_layout: BindGroupLayout,
rsc_group: BindGroup, rsc_group: BindGroup,
@@ -34,28 +145,76 @@ pub struct UiRenderNode {
active: Vec<usize>, active: Vec<usize>,
window_buffer: Buffer, window_buffer: Buffer,
textures: GpuTextures, textures: GpuTextures,
/// Every primitive's placement, read by the vertex stage for the
/// primitive being drawn and by the fragment stage for a mask's.
instances: ArrBuf<PrimitiveInstance>,
masks: ArrBuf<Mask>, masks: ArrBuf<Mask>,
move_offsets: ArrBuf<MoveOffset>,
/// Group 3: the masks and move-offsets storage buffers, on their own --
/// see IRIS_TODO.md's "Appending one image ... rebuilds every other
/// image's bind group". These used to live in group 2 alongside each
/// standalone image's own texture view, so an image's bind group named
/// the masks/move_offsets buffer directly; the moment either buffer
/// resized (which a widget getting its *first* move slot can trigger,
/// unrelated to any image), `ArrBuf::update` handed back a new `Buffer`
/// identity and every image's bind group -- one per live image -- had
/// to be rebuilt to reference it. Pulling both buffers into their own
/// group, bound once per frame rather than once per draw call, means a
/// buffer resize now rebuilds exactly this one group instead of N.
masks_layout: BindGroupLayout,
masks_group: BindGroup,
} }
/// One layer's vertex buffers: the slots it draws, in order. The
/// primitives themselves are in `UiRenderNode::instances`.
struct RenderLayer { struct RenderLayer {
instance: ArrBuf<PrimitiveInstance>, order: ArrBuf<u32>,
primitives: PrimitiveBuffers, /// A standalone image's slots, kept apart from `order` because each
primitive_group: BindGroup, /// one draws with its own bind group -- see `UiRenderNode::draw`.
images: ArrBuf<u32>,
/// The texture slot each entry of `images` draws with, in the same
/// order, refreshed alongside it. Not in the vertex buffer itself
/// because it names a bind group, not shader data.
image_tex_indices: Vec<u32>,
} }
impl UiRenderNode { impl UiRenderNode {
pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) { pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) {
pass.set_pipeline(&self.pipeline); pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.uniform_group, &[]); pass.set_bind_group(0, &self.uniform_group, &[]);
pass.set_bind_group(2, &self.rsc_group, &[]); // Group 1 is global now, so it is set here rather than per layer.
pass.set_bind_group(1, &self.primitive_group, &[]);
// Set once, not per layer or per image: masks/move_offsets are read
// by every primitive and every standalone image alike, and living
// in their own group (rather than folded into group 2 alongside the
// per-image texture view) is what keeps an image's own bind group
// from naming a buffer that changes size on an unrelated widget's
// first draw -- see the comment on `masks_group` below.
pass.set_bind_group(3, &self.masks_group, &[]);
for i in &self.active { for i in &self.active {
let layer = &self.layers[i]; let layer = &self.layers[i];
if layer.instance.len() == 0 { if layer.order.len() == 0 && layer.images.len() == 0 {
continue; continue;
} }
pass.set_bind_group(1, &layer.primitive_group, &[]); if layer.order.len() > 0 {
pass.set_vertex_buffer(0, layer.instance.buffer.slice(..)); pass.set_bind_group(2, &self.rsc_group, &[]);
pass.draw(0..4, 0..layer.instance.len() as u32); pass.set_vertex_buffer(0, layer.order.buffer.slice(..));
pass.draw(0..4, 0..layer.order.len() as u32);
}
// Images draw after this layer's rects and glyphs, one draw call
// each with its own bind group. That draws every image "on top"
// within the layer, which loses nothing that currently exists:
// `Primitives::apply_free` frees with `swap_remove`, so a layer's
// draw order was already undefined before images had their own
// list -- nothing before this relied on interleaving a rect
// between two images at a particular position.
if layer.images.len() > 0 {
pass.set_vertex_buffer(0, layer.images.buffer.slice(..));
for (k, &tex_idx) in layer.image_tex_indices.iter().enumerate() {
pass.set_bind_group(2, self.textures.image_bind_group(tex_idx), &[]);
pass.draw(0..4, k as u32..k as u32 + 1);
}
}
} }
} }
@@ -65,79 +224,156 @@ impl UiRenderNode {
queue: &Queue, queue: &Queue,
ui: &mut UiData, ui: &mut UiData,
ui_render: &mut UiRenderState, ui_render: &mut UiRenderState,
) { ) -> FrameUpdateStats {
self.active.clear(); self.active.clear();
for (i, primitives) in ui_render.layers.iter_mut() { for (i, order) in ui_render.layers.iter_mut() {
self.active.push(i); self.active.push(i);
for change in primitives.apply_free() { let rlayer = self.layers.entry(i).or_insert_with(|| RenderLayer {
if let Some(inst) = ui_render.active.get_mut(&change.id) { order: ArrBuf::new(
for h in &mut inst.primitives {
if h.layer == i && h.inst_idx == change.old {
h.inst_idx = change.new;
break;
}
}
}
}
let rlayer = self.layers.entry(i).or_insert_with(|| {
let primitives = PrimitiveBuffers::new(device);
let primitive_group =
Self::primitive_group(device, &self.primitive_layout, primitives.buffers());
RenderLayer {
instance: ArrBuf::new(
device, device,
BufferUsages::VERTEX | BufferUsages::COPY_DST, BufferUsages::VERTEX | BufferUsages::COPY_DST,
"instance", "layer order",
), ),
primitives, images: ArrBuf::new(
primitive_group,
}
});
if primitives.updated {
rlayer
.instance
.update(device, queue, primitives.instances());
rlayer.primitives.update(device, queue, primitives.data());
rlayer.primitive_group = Self::primitive_group(
device, device,
&self.primitive_layout, BufferUsages::VERTEX | BufferUsages::COPY_DST,
rlayer.primitives.buffers(), "layer image order",
); ),
primitives.updated = false; image_tex_indices: Vec::new(),
});
if order.updated {
rlayer.order.update(device, queue, order.order());
rlayer.images.update(device, queue, order.images());
rlayer.image_tex_indices = order
.images()
.iter()
.map(|&slot| ui_render.primitives.instance(slot).idx)
.collect();
order.updated = false;
} }
} }
let mut changed = false; let instances_resized = if ui_render.primitives.updated {
changed |= self.textures.update(&mut ui.textures); ui_render.primitives.updated = false;
if ui.masks.changed { let resized = self
.instances
.update(device, queue, ui_render.primitives.instances());
self.primitives
.update(device, queue, ui_render.primitives.data());
self.primitive_group =
Self::primitive_group(device, &self.primitive_layout, self.primitives.buffers());
resized
} else {
false
};
let masks_resized = if ui.masks.changed {
ui.masks.changed = false; ui.masks.changed = false;
self.masks.update(device, queue, &ui.masks[..]); self.masks.update(device, queue, &ui.masks[..])
changed = true; } else {
false
};
let moves_resized = if ui.move_offsets.changed {
ui.move_offsets.changed = false;
self.move_offsets
.update(device, queue, &ui.move_offsets[..])
} else {
false
};
if masks_resized || moves_resized || instances_resized {
self.masks_group = Self::masks_group(
device,
&self.masks_layout,
&self.masks,
&self.move_offsets,
&self.instances,
);
} }
if changed { let rebuild_main = self.textures.update(&mut ui.textures, &self.rsc_layout);
self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures, &self.masks); if rebuild_main {
self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures);
}
FrameUpdateStats {
masks_resized,
moves_resized,
} }
} }
pub fn resize(&mut self, size: &PhysicalSize<u32>, queue: &Queue) { /// Takes a size rather than a window type: this is the only thing the
/// core wanted from winit, and depending on a windowing backend for two
/// numbers is what put `android-activity` in the core's graph for an
/// Android build that is meant to go through android-view instead.
pub fn resize(&mut self, size: impl Into<Vec2>, queue: &Queue) {
let size = size.into();
let slice = &[WindowUniform { let slice = &[WindowUniform {
width: size.width as f32, width: size.x,
height: size.height as f32, height: size.y,
}]; }];
queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice)); queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice));
} }
/// Builds every bind group layout, the pipeline, and the two storage
/// buffers this needs -- fallibly, since this is exactly the call that
/// aborted the process on Iris's phone in a release build with no
/// message beyond "wgpu error: Validation Error" (RUST.md's P0 box,
/// "iris bench crash on the phone, 2026-09-06"). wgpu's own default
/// behaviour for an uncaptured error is `panic!` with no caller able to
/// intervene, so every `create_bind_group_layout`/`create_render_pipeline`
/// call below runs inside three nested error scopes (one per
/// `ErrorFilter`) instead: whichever scope catches something, its
/// `wgpu::Error`'s `Display` is wgpu-core's own `format_error` output
/// (`"Validation Error\n\nCaused by:\n ..."`, the same text the panic
/// would have printed before Android's crash reporter truncated it) and
/// becomes this function's `Err`. Both callers
/// (`android::render::AndroidRenderer::new`, `default::render::
/// UiRenderer::new`) already call `Device`-creation with
/// `pollster::block_on`, so returning a plain `Result` here rather than
/// making this `async fn` keeps that same synchronous shape.
pub fn new( pub fn new(
device: &Device, device: &Device,
queue: &Queue, queue: &Queue,
config: &SurfaceConfiguration, config: &SurfaceConfiguration,
limits: UiLimits, window_size: impl Into<Vec2>,
) -> Self { ) -> Result<Self, String> {
// Popped in reverse of this order, once every creation call below
// has run -- `Device::push_error_scope`'s own contract.
let oom_scope = device.push_error_scope(ErrorFilter::OutOfMemory);
let validation_scope = device.push_error_scope(ErrorFilter::Validation);
let internal_scope = device.push_error_scope(ErrorFilter::Internal);
let shader = device.create_shader_module(ShaderModuleDescriptor { let shader = device.create_shader_module(ShaderModuleDescriptor {
label: Some("UI Shape Shader"), label: Some("UI Shape Shader"),
source: ShaderSource::Wgsl(SHAPE_SHADER.into()), source: ShaderSource::Wgsl(SHAPE_SHADER.into()),
}); });
let window_uniform = WindowUniform::default(); // Seeded from the caller's own reported size, not
// `WindowUniform::default()` (0, 0): the vertex shader divides by
// `window.dim` to reach clip space, so a window this buffer
// disagrees with means every primitive's position is NaN/Inf and is
// dropped before rasterization -- the clear colour still reaches
// the screen (the pass runs regardless) while nothing drawn on top
// of it ever does. winit's backend gets away with the old default
// because winit fires an initial `WindowEvent::Resized` that calls
// `resize()` before the first frame; android-view has no such
// automatic event, so `AndroidRenderer::new` built a node whose
// window buffer was never corrected -- this is I2's "nothing draws"
// bug (RUST.md).
//
// **Deliberately not `config.width`/`config.height`**: those are
// the surface's *physical* pixel size, which the swapchain needs,
// but everything downstream of this uniform (layout, hit-testing,
// glyph/rect positions) works in the caller's own units -- on
// Android that's *logical* (physical / density) since RUST.md's P0
// box ("text is far too small"), on desktop it's whatever
// `default::render::UiRenderer::new` already divides by
// `window.scale_factor()`. Passing it in explicitly, rather than
// deriving it from `config` here, is what keeps this crate from
// needing to know either platform's notion of density at all.
let window_uniform = {
let size = window_size.into();
WindowUniform {
width: size.x,
height: size.y,
}
};
let window_buffer = device.create_buffer_init(&BufferInitDescriptor { let window_buffer = device.create_buffer_init(&BufferInitDescriptor {
label: Some("window"), label: Some("window"),
contents: bytemuck::cast_slice(&[window_uniform]), contents: bytemuck::cast_slice(&[window_uniform]),
@@ -161,9 +397,8 @@ impl UiRenderNode {
let uniform_group = Self::bind_group_0(device, &uniform_layout, &window_buffer); let uniform_group = Self::bind_group_0(device, &uniform_layout, &window_buffer);
let primitive_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor { let primitive_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &core::array::from_fn::<_, { PrimitiveBuffers::LEN }, _>(|i| { entries: &PrimitiveBuffers::BINDINGS.map(|binding| BindGroupLayoutEntry {
BindGroupLayoutEntry { binding,
binding: i as u32,
visibility: ShaderStages::FRAGMENT, visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer { ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true }, ty: BufferBindingType::Storage { read_only: true },
@@ -171,24 +406,44 @@ impl UiRenderNode {
min_binding_size: None, min_binding_size: None,
}, },
count: None, count: None,
}
}), }),
label: Some("primitive"), label: Some("primitive"),
}); });
let tex_manager = GpuTextures::new(device, queue); let tex_manager = GpuTextures::new(device, queue);
let primitives = PrimitiveBuffers::new(device);
let primitive_group =
Self::primitive_group(device, &primitive_layout, primitives.buffers());
let instances = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui instances",
);
let masks = ArrBuf::new( let masks = ArrBuf::new(
device, device,
BufferUsages::STORAGE | BufferUsages::COPY_DST, BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui masks", "ui masks",
); );
let move_offsets = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui move offsets",
);
let rsc_layout = Self::rsc_layout(device, &limits); let rsc_layout = Self::rsc_layout(device);
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager, &masks); let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager);
let masks_layout = Self::masks_layout(device);
let masks_group =
Self::masks_group(device, &masks_layout, &masks, &move_offsets, &instances);
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor { let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some("UI Shape Pipeline Layout"), label: Some("UI Shape Pipeline Layout"),
bind_group_layouts: &[&uniform_layout, &primitive_layout, &rsc_layout], bind_group_layouts: &[
Some(&uniform_layout),
Some(&primitive_layout),
Some(&rsc_layout),
Some(&masks_layout),
],
immediate_size: 0, immediate_size: 0,
}); });
let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor { let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
@@ -197,7 +452,7 @@ impl UiRenderNode {
vertex: VertexState { vertex: VertexState {
module: &shader, module: &shader,
entry_point: Some("vs_main"), entry_point: Some("vs_main"),
buffers: &[PrimitiveInstance::desc()], buffers: &[Some(instance_slot_layout())],
compilation_options: Default::default(), compilation_options: Default::default(),
}, },
fragment: Some(FragmentState { fragment: Some(FragmentState {
@@ -229,9 +484,22 @@ impl UiRenderNode {
cache: None, cache: None,
}); });
Self { // Reverse of the push order above. Only one of these should ever be
// `Some` in practice -- three separate scopes exist to name *which*
// kind of error it was, not because more than one is expected at
// once.
let internal_err = internal_scope.pop().block_on();
let validation_err = validation_scope.pop().block_on();
let oom_err = oom_scope.pop().block_on();
if let Some(err) = validation_err.or(oom_err).or(internal_err) {
return Err(err.to_string());
}
Ok(Self {
uniform_group, uniform_group,
primitive_layout, primitive_layout,
primitives,
primitive_group,
rsc_layout, rsc_layout,
rsc_group, rsc_group,
pipeline, pipeline,
@@ -239,8 +507,12 @@ impl UiRenderNode {
layers: HashMap::default(), layers: HashMap::default(),
active: Vec::new(), active: Vec::new(),
textures: tex_manager, textures: tex_manager,
instances,
masks, masks,
} move_offsets,
masks_layout,
masks_group,
})
} }
fn bind_group_0( fn bind_group_0(
@@ -273,7 +545,14 @@ impl UiRenderNode {
}) })
} }
fn rsc_layout(device: &Device, limits: &UiLimits) -> BindGroupLayout { /// Group 2: the shared atlas array and one standalone-image slot (a null
/// view for the main draw, a real one for each image's own bind group --
/// see `GpuTextures`), plus one sampler. No `count` on any entry: this
/// needs nothing beyond plain Vulkan 1.0 / GLES sampling, unlike the
/// `binding_array` layout it replaced (see TEXTURES.md's "Recommended
/// shape"). Masks and move_offsets are deliberately *not* here -- see
/// `masks_layout` below for why they get their own group.
fn rsc_layout(device: &Device) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor { device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[ entries: &[
BindGroupLayoutEntry { BindGroupLayoutEntry {
@@ -281,20 +560,91 @@ impl UiRenderNode {
visibility: ShaderStages::FRAGMENT, visibility: ShaderStages::FRAGMENT,
ty: BindingType::Texture { ty: BindingType::Texture {
sample_type: TextureSampleType::Float { filterable: false }, sample_type: TextureSampleType::Float { filterable: false },
view_dimension: TextureViewDimension::D2, view_dimension: TextureViewDimension::D2Array,
multisampled: false, multisampled: false,
}, },
count: Some(NonZero::new(limits.max_textures).unwrap()), count: None,
}, },
BindGroupLayoutEntry { BindGroupLayoutEntry {
binding: 1, binding: 1,
visibility: ShaderStages::FRAGMENT, visibility: ShaderStages::FRAGMENT,
ty: BindingType::Sampler(SamplerBindingType::NonFiltering), ty: BindingType::Texture {
count: Some(NonZero::new(limits.max_samplers).unwrap()), sample_type: TextureSampleType::Float { filterable: false },
view_dimension: TextureViewDimension::D2,
multisampled: false,
},
count: None,
}, },
BindGroupLayoutEntry { BindGroupLayoutEntry {
binding: 2, binding: 2,
visibility: ShaderStages::FRAGMENT, visibility: ShaderStages::FRAGMENT,
ty: BindingType::Sampler(SamplerBindingType::NonFiltering),
count: None,
},
],
label: Some("ui rsc"),
})
}
/// The main group: rects and glyphs never sample the image slot, so it
/// gets a 1x1 null view rather than any live standalone image's.
fn rsc_group(
device: &Device,
layout: &BindGroupLayout,
tex_manager: &GpuTextures,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
entries: &[
BindGroupEntry {
binding: 0,
resource: BindingResource::TextureView(tex_manager.array_view()),
},
BindGroupEntry {
binding: 1,
resource: BindingResource::TextureView(tex_manager.null_view()),
},
BindGroupEntry {
binding: 2,
resource: BindingResource::Sampler(tex_manager.sampler()),
},
],
label: Some("ui rsc"),
})
}
/// Group 3: the masks and move_offsets storage buffers, shared by the
/// main draw and every standalone image alike (see the field comment on
/// `masks_group`). Bound once per frame in `draw()` rather than folded
/// into group 2, so a resize of either buffer -- which an unrelated
/// widget's first move slot can trigger -- rebuilds this one group
/// instead of every image's.
fn masks_layout(device: &Device) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[
BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
BindGroupLayoutEntry {
binding: 2,
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
ty: BindingType::Buffer { ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true }, ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false, has_dynamic_offset: false,
@@ -303,60 +653,67 @@ impl UiRenderNode {
count: None, count: None,
}, },
], ],
label: Some("ui rsc"), label: Some("ui masks"),
}) })
} }
fn rsc_group( fn masks_group(
device: &Device, device: &Device,
layout: &BindGroupLayout, layout: &BindGroupLayout,
tex_manager: &GpuTextures,
masks: &ArrBuf<Mask>, masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
instances: &ArrBuf<PrimitiveInstance>,
) -> BindGroup { ) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor { device.create_bind_group(&BindGroupDescriptor {
layout, layout,
entries: &[ entries: &[
BindGroupEntry { BindGroupEntry {
binding: 0, binding: 0,
resource: BindingResource::TextureViewArray(&tex_manager.views()), resource: masks.buffer.as_entire_binding(),
}, },
BindGroupEntry { BindGroupEntry {
binding: 1, binding: 1,
resource: BindingResource::SamplerArray(&tex_manager.samplers()), resource: move_offsets.buffer.as_entire_binding(),
}, },
BindGroupEntry { BindGroupEntry {
binding: 2, binding: 2,
resource: masks.buffer.as_entire_binding(), resource: instances.buffer.as_entire_binding(),
}, },
], ],
label: Some("ui rsc"), label: Some("ui masks"),
}) })
} }
pub fn view_count(&self) -> usize { pub fn view_count(&self) -> usize {
self.textures.view_count() self.textures.view_count()
} }
/// Standalone-image bind groups built since the last call -- see
/// `GpuTextures::take_bind_group_creates`. Call once per frame before
/// `update()` to measure exactly that frame.
pub fn take_image_bind_group_creates(&mut self) -> u64 {
self.textures.take_bind_group_creates()
} }
pub struct UiLimits { /// Atlas-array `grow_array` calls since the last call -- same calling
max_textures: u32, /// convention as `take_image_bind_group_creates` (call once per frame,
max_samplers: u32, /// before `update()`, to read exactly the previous frame's tally). Part
} /// of the Diagnostics page's per-frame report (RUST.md's P0 box, "the
/// first input frame" investigation): if a report ever shows a grow
impl Default for UiLimits { /// landing on the same frame the glyphs vanished, that is the
fn default() -> Self { /// coincidence to chase first.
Self { pub fn take_atlas_pages_grown(&mut self) -> u64 {
max_textures: 100000, self.textures.take_pages_grown()
max_samplers: 1000,
}
} }
} }
impl UiLimits { /// What `UiRenderNode::update` changed this frame that a caller building a
pub fn max_binding_array_elements_per_shader_stage(&self) -> u32 { /// per-frame diagnostic report cares about -- see `take_image_bind_group_creates`/
self.max_textures + self.max_samplers /// `take_atlas_pages_grown` for the two counters this doesn't carry (they
} /// use the existing "call before update()" convention instead, so as not
pub fn max_binding_array_sampler_elements_per_shader_stage(&self) -> u32 { /// to disturb `bench_images`' documented counts).
self.max_samplers #[derive(Clone, Copy, Debug, Default)]
} pub struct FrameUpdateStats {
pub masks_resized: bool,
pub moves_resized: bool,
} }
+398 -72
View File
@@ -4,35 +4,27 @@ use crate::{
Color, UiRegion, WidgetId, Color, UiRegion, WidgetId,
render::{ render::{
ArrBuf, ArrBuf,
data::{MaskIdx, PrimitiveInstance}, data::{MaskIdx, MoveIdx, PrimitiveInstance},
}, },
util::HashSet,
}; };
use bytemuck::Pod; use bytemuck::Pod;
use wgpu::*; use wgpu::*;
pub struct Primitives { /// The `binding` tag `Painter` writes on an image instance. Distinct from any
instances: Vec<PrimitiveInstance>, /// `Primitive::BINDING` because images have no `PrimitiveData` entry to key
assoc: Vec<WidgetId>, /// one from -- a bind group already selects the texture -- so this only ever
data: PrimitiveData, /// has to match the shader's `TEXTURE` constant and flag "this instance is
free: Vec<usize>, /// drawn with its own bind group" to the code below.
pub updated: bool, pub const IMAGE_BINDING: u32 = 1;
}
impl Default for Primitives {
fn default() -> Self {
Self {
instances: Default::default(),
assoc: Default::default(),
data: Default::default(),
free: Vec::new(),
updated: true,
}
}
}
pub trait Primitive: Pod { pub trait Primitive: Pod {
const BINDING: u32; const BINDING: u32;
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self>; fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self>;
/// The read-only half of [`Self::vec`], for a caller that wants to
/// look one entry up rather than write one -- a mask reading the
/// radius of the rect it clips to ([`Primitives::data`]).
fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec<Self>;
} }
macro_rules! primitives { macro_rules! primitives {
@@ -54,6 +46,14 @@ macro_rules! primitives {
impl PrimitiveBuffers { impl PrimitiveBuffers {
pub const LEN: usize = primitives!(@count $($name)*); pub const LEN: usize = primitives!(@count $($name)*);
/// The group-1 binding number each primitive's storage buffer
/// sits at, in declaration order. Not `0..LEN`: a primitive's
/// `BINDING` also tags its instances for the shader's dispatch
/// switch, and a removed primitive (as `TEXTURE` was, once
/// images stopped needing a per-instance storage entry) can
/// leave a gap, so the pipeline layout has to ask for these
/// exact numbers rather than assuming they are contiguous.
pub const BINDINGS: [u32; Self::LEN] = [$(<$ty>::BINDING,)*];
pub fn buffers(&self) -> [(u32, &Buffer); Self::LEN] { pub fn buffers(&self) -> [(u32, &Buffer); Self::LEN] {
[ [
$((<$ty>::BINDING, &self.$name.buffer),)* $((<$ty>::BINDING, &self.$name.buffer),)*
@@ -90,73 +90,236 @@ macro_rules! primitives {
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self> { fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self> {
&mut data.$name &mut data.$name
} }
fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec<Self> {
&data.$name
}
} }
)* )*
}; };
(@count $t1:tt $($t:tt)+) => { 1 + primitives!(@count $($t),+) }; // The recursion has to hand back the same shape it matches -- space
// separated, not comma separated. Written with `$($t),+` it re-entered
// with a comma as the first token and never terminated, which happened to
// work only because there were exactly two primitives: the first step left
// a single token, and a single token matches the base case whichever
// separator it was written with.
(@count $t1:tt $($t:tt)+) => { 1 + primitives!(@count $($t)+) };
(@count $t:tt) => { 1 }; (@count $t:tt) => { 1 };
} }
pub struct PrimitiveInst<P> { /// Every primitive instance in the tree, in one arena that all layers
pub id: WidgetId, /// share, plus the per-primitive data (`rects`, `glyphs`) they index.
pub primitive: P, ///
pub region: UiRegion, /// **Why one arena rather than one per layer**, which is what this was:
pub mask_idx: MaskIdx, /// the fragment stage evaluates a *mask's* primitive at the masked pixel
/// (LAYOUT.md's "Masks with a shape"), and the widget that owns a mask is
/// routinely in a different layer from the content it clips -- a rounded
/// container in one layer, a `Stack`'s child content in the layer below.
/// A per-layer buffer cannot answer that lookup at all: only one layer's
/// group is bound at a time, so the mask would silently read another
/// layer's rect. Both buffers are therefore global and bound once per
/// frame, and a layer keeps only its draw *order* ([`LayerOrder`]).
///
/// Slots are stable for a primitive's whole life: nothing here is
/// compacted, so a `Mask` can hold a slot across frames.
pub struct Primitives {
instances: Vec<PrimitiveInstance>,
assoc: Vec<WidgetId>,
/// Where each slot's [`PrimitiveHandle`] sits in its owner's
/// `ActiveData::primitives` -- the index that makes
/// `UiRenderState::apply_free` O(1) per renumbered primitive instead
/// of a scan of everything the owner drew. Written by
/// [`Self::set_handle_index`] from the one place a handle is taken
/// into that vec (`Painter::own`), and dead alongside its `assoc`
/// entry, which is what keeps the two in step.
///
/// Without it a text widget that is freed and redrawn in one frame
/// costs O(glyphs^2): every one of its glyphs is renumbered, and each
/// renumbering scanned all of them. Measured 2026-09-08 at 1.37s for a
/// 51,200-glyph block on this machine, against 20ms for the shaping
/// and rasterising of the same text.
handle_idx: Vec<u32>,
/// Slots freed since the last [`Self::apply_free`]. Deliberately not
/// reusable yet: the layer that drew one still names it in its draw
/// order until that call compacts the order, so handing it out again
/// first would draw the new primitive twice -- once through the stale
/// order entry and once through the new one.
freed: Vec<usize>,
/// Slots [`Self::apply_free`] released, which is what [`Self::alloc`]
/// hands out.
reusable: Vec<usize>,
data: PrimitiveData,
/// Whether the instance arena or the per-primitive data changed since
/// the last upload -- one flag for both, since they are uploaded
/// together.
pub updated: bool,
}
impl Default for Primitives {
fn default() -> Self {
Self {
instances: Default::default(),
assoc: Default::default(),
handle_idx: Default::default(),
freed: Vec::new(),
reusable: Vec::new(),
data: Default::default(),
updated: true,
}
}
} }
impl Primitives { impl Primitives {
pub fn write<P: Primitive>( /// A slot whose handle has not been recorded yet -- see
/// [`Self::handle_idx`]. No owner draws four billion primitives, so
/// the sentinel cannot collide with a real index.
const NO_HANDLE: u32 = u32::MAX;
/// Writes a primitive into the arena and hands back its slot and its
/// entry in the per-primitive data. The caller (`UiRenderState`) puts
/// the slot into a layer's draw order -- an instance that no layer
/// names is never rasterized, which is what a mask shape drawn only to
/// be *referenced* uses.
pub fn alloc<P: Primitive>(
&mut self, &mut self,
layer: usize,
PrimitiveInst { PrimitiveInst {
id, id,
primitive, primitive,
region, region,
mask_idx, mask_idx,
move_idx,
}: PrimitiveInst<P>, }: PrimitiveInst<P>,
) -> PrimitiveHandle { ) -> (u32, usize) {
self.updated = true; let data_idx = P::vec(&mut self.data).add(primitive);
let vec = P::vec(&mut self.data); let slot = self.push(
let i = vec.add(primitive); PrimitiveInstance {
let inst = PrimitiveInstance {
region, region,
idx: i as u32, idx: data_idx as u32,
mask_idx, mask_idx,
move_idx,
binding: P::BINDING, binding: P::BINDING,
}; },
let inst_i = if let Some(i) = self.free.pop() { id,
);
(slot, data_idx)
}
/// A standalone image, which has no `PrimitiveData` entry to allocate
/// -- its bind group already picks the texture, so `texture_idx` rides
/// in the otherwise-unused `idx` field and names the bind group the
/// draw call selects.
pub fn alloc_image(
&mut self,
id: WidgetId,
texture_idx: u32,
region: UiRegion,
mask_idx: MaskIdx,
move_idx: MoveIdx,
) -> u32 {
self.push(
PrimitiveInstance {
region,
idx: texture_idx,
mask_idx,
move_idx,
binding: IMAGE_BINDING,
},
id,
)
}
fn push(&mut self, inst: PrimitiveInstance, id: WidgetId) -> u32 {
self.updated = true;
let slot = if let Some(i) = self.reusable.pop() {
self.instances[i] = inst; self.instances[i] = inst;
self.assoc[i] = id; self.assoc[i] = id;
self.handle_idx[i] = Self::NO_HANDLE;
i i
} else { } else {
let i = self.instances.len();
self.instances.push(inst); self.instances.push(inst);
self.assoc.push(id); self.assoc.push(id);
i self.handle_idx.push(Self::NO_HANDLE);
self.instances.len() - 1
}; };
PrimitiveHandle::new::<P>(layer, inst_i, i) slot as u32
}
/// returns (old index, new index)
pub fn apply_free(&mut self) -> impl Iterator<Item = PrimitiveChange> {
self.free.sort_by(|a, b| b.cmp(a));
self.free.drain(..).filter_map(|i| {
self.instances.swap_remove(i);
self.assoc.swap_remove(i);
if i == self.instances.len() {
return None;
}
let id = self.assoc[i];
let old = self.instances.len();
Some(PrimitiveChange { id, old, new: i })
})
} }
/// Retires a slot, answering the mask it was drawn under so the caller
/// can drop that mask's ref. The slot itself only becomes reusable at
/// the next [`Self::apply_free`] -- see `freed`.
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx { pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
self.updated = true; self.updated = true;
let slot = h.slot as usize;
if h.binding != IMAGE_BINDING {
self.data.free(h.binding, h.data_idx); self.data.free(h.binding, h.data_idx);
self.free.push(h.inst_idx); }
self.instances[h.inst_idx].mask_idx self.freed.push(slot);
self.instances[slot].mask_idx
}
/// Hands this frame's freed slots back for reuse. Called once per
/// frame from `UiRenderState::update`, **after** every layer has
/// compacted its draw order, since that order is the only thing still
/// naming them.
pub fn release_freed(&mut self) {
self.reusable.append(&mut self.freed);
}
/// Which widget drew the primitive in `slot` -- how a draw-order
/// change finds the handle it has to renumber.
pub fn owner(&self, slot: u32) -> WidgetId {
self.assoc[slot as usize]
}
/// Records that `slot`'s handle is `idx` entries into its owner's
/// `ActiveData::primitives`. Called once per primitive, by the one
/// place that puts a handle into that vec.
pub fn set_handle_index(&mut self, slot: u32, idx: u32) {
self.handle_idx[slot as usize] = idx;
}
/// Where `slot`'s handle sits in its owner's `ActiveData::primitives`
/// -- see [`Self::handle_idx`]. `None` only for a slot whose owner
/// never took the handle, which nothing in this crate does.
pub fn handle_index(&self, slot: u32) -> Option<usize> {
match self.handle_idx[slot as usize] {
Self::NO_HANDLE => None,
idx => Some(idx as usize),
}
}
pub fn clear(&mut self) {
self.updated = true;
self.instances.clear();
self.assoc.clear();
self.handle_idx.clear();
self.freed.clear();
self.reusable.clear();
self.data.clear();
}
/// How many instances are still live -- the O(1) half of the orphan
/// check, so the O(primitives) walk below only runs on a frame that
/// already looks wrong. See
/// [`crate::UiRenderState::orphaned_primitives`].
pub fn live_count(&self) -> usize {
self.instances.len() - self.freed.len() - self.reusable.len()
}
/// Every live instance as `(slot, owner, is_image)` -- everything
/// except the freed and the reusable. Only
/// [`crate::UiRenderState::orphaned_primitives`] uses this, to check
/// that every live primitive still belongs to a live widget.
pub fn live_instances(&self) -> impl Iterator<Item = (u32, WidgetId, bool)> + '_ {
let dead: HashSet<usize> = self.freed.iter().chain(&self.reusable).copied().collect();
(0..self.instances.len())
.filter(move |i| !dead.contains(i))
.map(|i| {
(
i as u32,
self.assoc[i],
self.instances[i].binding == IMAGE_BINDING,
)
})
} }
pub fn data(&self) -> &PrimitiveData { pub fn data(&self) -> &PrimitiveData {
@@ -167,40 +330,166 @@ impl Primitives {
&self.instances &self.instances
} }
pub fn instance(&self, slot: u32) -> &PrimitiveInstance {
&self.instances[slot as usize]
}
/// The per-primitive data behind `slot`, or `None` if that slot holds
/// a different kind of primitive -- the `binding` check is the same
/// one the shader's dispatch switch makes, and it is what stops a
/// caller reading a glyph's index into the rect table.
pub fn primitive_data<P: Primitive>(&self, slot: u32) -> Option<&P> {
let inst = self.instance(slot);
(inst.binding == P::BINDING).then(|| &P::vec_ref(&self.data)[inst.idx as usize])
}
pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion { pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion {
self.updated = true; self.updated = true;
&mut self.instances[h.inst_idx].region &mut self.instances[h.slot as usize].region
} }
} }
pub struct PrimitiveChange { /// One layer's draw order: the slots of the global arena it draws, in the
pub id: WidgetId, /// order they were written. The vertex buffer of a layer is exactly this.
pub old: usize, ///
pub new: usize, /// Both lists free with `swap_remove`, so a layer's draw order was already
/// undefined before this split: nothing here may assume one primitive
/// stays adjacent to another once anything in the layer has been freed.
#[derive(Default)]
pub struct LayerOrder {
order: Vec<u32>,
/// Standalone images, kept apart because each draws with its own bind
/// group rather than sharing the layer's one instanced draw -- see
/// `UiRenderNode::draw`.
images: Vec<u32>,
free: Vec<usize>,
image_free: Vec<usize>,
pub updated: bool,
} }
impl LayerOrder {
pub fn push(&mut self, slot: u32, is_image: bool) -> usize {
self.updated = true;
let list = if is_image {
&mut self.images
} else {
&mut self.order
};
list.push(slot);
list.len() - 1
}
/// Marks a position for removal. Deferred to [`Self::apply_free`] like
/// the arena's own, so that a position is only renumbered once per
/// frame however many were dropped.
pub fn free(&mut self, pos: usize, is_image: bool) {
self.updated = true;
if is_image {
self.image_free.push(pos);
} else {
self.free.push(pos);
}
}
/// Compacts both lists, answering every primitive whose position
/// moved so its handle can be corrected.
pub fn apply_free(&mut self) -> Vec<OrderChange> {
let mut changes = Self::apply_free_list(&mut self.free, &mut self.order, false);
changes.extend(Self::apply_free_list(
&mut self.image_free,
&mut self.images,
true,
));
changes
}
fn apply_free_list(
free: &mut Vec<usize>,
list: &mut Vec<u32>,
is_image: bool,
) -> Vec<OrderChange> {
// Descending, so removing a contiguous tail costs no renumbering
// at all -- which is what freeing one widget's primitives is.
free.sort_by(|a, b| b.cmp(a));
free.drain(..)
.filter_map(|pos| {
list.swap_remove(pos);
if pos == list.len() {
return None;
}
Some(OrderChange {
slot: list[pos],
is_image,
pos,
})
})
.collect()
}
pub fn order(&self) -> &Vec<u32> {
&self.order
}
pub fn images(&self) -> &Vec<u32> {
&self.images
}
}
/// A primitive whose position in a layer's draw order moved when
/// something before it was freed -- `slot` names which primitive, so its
/// owner's handle can be found and pointed at `pos`.
pub struct OrderChange {
pub slot: u32,
/// Which of the layer's two lists moved: their positions are
/// independent index spaces, so a handle matching on position alone
/// could take an image's renumbering for a rect's.
pub is_image: bool,
pub pos: usize,
}
/// Whether a primitive goes into its layer's draw order. [`Drawn::No`] is
/// a primitive written only to be *referenced* -- a mask's shape
/// (LAYOUT.md's "Masks with a shape"). It is owned, moved, resized and
/// freed exactly like any other; it is simply never rasterized.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Drawn {
Yes,
No,
}
/// The `pos` of a [`Drawn::No`] primitive: it is in no layer's order, so
/// there is no position to renumber or free.
pub const NOT_DRAWN: usize = usize::MAX;
/// Where one primitive lives: its stable slot in the global arena, and
/// where in a layer's draw order it currently sits ([`NOT_DRAWN`] if it is
/// only referenced).
#[derive(Debug)] #[derive(Debug)]
pub struct PrimitiveHandle { pub struct PrimitiveHandle {
pub layer: usize, pub layer: usize,
pub inst_idx: usize, pub pos: usize,
pub slot: u32,
pub data_idx: usize, pub data_idx: usize,
pub binding: u32, pub binding: u32,
} }
impl PrimitiveHandle { impl PrimitiveHandle {
fn new<P: Primitive>(layer: usize, inst_idx: usize, data_idx: usize) -> Self { pub fn is_image(&self) -> bool {
Self { self.binding == IMAGE_BINDING
layer,
inst_idx,
data_idx,
binding: P::BINDING,
} }
} }
pub struct PrimitiveInst<P> {
pub id: WidgetId,
pub primitive: P,
pub region: UiRegion,
pub mask_idx: MaskIdx,
pub move_idx: MoveIdx,
} }
primitives!( primitives!(
rects: RectPrimitive => 0, rects: RectPrimitive => 0,
textures: TexturePrimitive => 1, glyphs: GlyphPrimitive => 2,
); );
#[repr(C)] #[repr(C)]
@@ -223,11 +512,48 @@ impl RectPrimitive {
} }
} }
/// One glyph, drawn as a sub-rectangle of the glyph atlas array.
///
/// `color` is the text colour and is multiplied by the atlas's alpha for an
/// ordinary mask glyph; a colour glyph (emoji) carries its own colour and
/// takes the atlas texel unchanged, which is what `IS_COLOR` selects.
#[repr(C)] #[repr(C)]
#[derive(Debug, Copy, Clone)] #[derive(Debug, Copy, Clone)]
pub struct TexturePrimitive { pub struct GlyphPrimitive {
pub view_idx: u32, pub uv_min: [f32; 2],
pub sampler_idx: u32, pub uv_max: [f32; 2],
/// Layer of the shared atlas array texture this glyph's page occupies --
/// not a bind-group or view index, since a page never gets one of its
/// own. See TEXTURES.md's "Recommended shape".
pub layer: u32,
pub color: Color<u8>,
pub flags: u32,
/// Pads this struct's Rust size to match WGSL's storage-buffer layout for
/// `GlyphInfo`: two `vec2<f32>` members give the struct an 8-byte
/// alignment, which rounds the WGSL size up to 32 bytes even though the
/// fields above only total 28. `bytemuck` does not check this for us.
_pad: u32,
}
impl GlyphPrimitive {
pub const IS_COLOR: u32 = 1;
pub fn new(
uv_min: [f32; 2],
uv_max: [f32; 2],
layer: u32,
color: Color<u8>,
flags: u32,
) -> Self {
Self {
uv_min,
uv_max,
layer,
color,
flags,
_pad: 0,
}
}
} }
pub struct PrimitiveVec<T> { pub struct PrimitiveVec<T> {
+54
View File
@@ -0,0 +1,54 @@
//! The rounded-rect coverage function, on the CPU.
//!
//! `shader.wgsl`'s `distance_from_rect`/`rounded_rect_coverage` are a
//! transliteration of these two, line for line, and
//! `mask_sdf_matches_the_shader` in `iris`'s layout tests compares the two
//! at a grid of points against values the shader itself produced. They are
//! kept together here, in the crate both a renderer and a hit test can
//! reach, because LAYOUT.md's "Masks with a shape" turns on the two
//! agreeing: a masked corner that cannot be tapped and a masked corner
//! that is not drawn have to be the same corner, and they are only the
//! same corner while one function decides both.
//!
//! Window pixels throughout, matching the shader's `pos` -- not `UiRegion`
//! units, which the shader has already resolved by the time it evaluates
//! this.
use crate::util::Vec2;
/// The signed distance from `pos` to a rounded rect given by its centre,
/// its corner offset (half its size) and its corner `radius`. Negative
/// inside.
pub fn distance_from_rect(pos: Vec2, center: Vec2, corner: Vec2, radius: f32) -> f32 {
// vec from center to pixel
let p = pos - center;
// vec from inner rect corner to pixel
let q = Vec2::new(
p.x.abs() - (corner.x - radius),
p.y.abs() - (corner.y - radius),
);
let clamped = Vec2::new(q.x.max(0.0), q.y.max(0.0));
(clamped.x * clamped.x + clamped.y * clamped.y).sqrt() - radius
}
/// How much of the pixel at `pos` a rounded rect covers, anti-aliased over
/// the half-pixel either side of its edge: 1 well inside, 0 well outside.
///
/// The half-pixel feather is why a hit test asks for **more than a half**
/// rather than "any coverage at all": half is where the geometric edge is,
/// so the two answer the same question the drawn shape does.
pub fn rounded_rect_coverage(pos: Vec2, top_left: Vec2, bot_right: Vec2, radius: f32) -> f32 {
let edge: f32 = 0.5;
let corner = (bot_right - top_left) / 2.0;
let center = top_left + corner;
let dist = distance_from_rect(pos, center, corner, radius);
1.0 - smoothstep(-edge.min(radius), edge, dist)
}
/// WGSL's `smoothstep`, which Rust has no equivalent of. Undefined in WGSL
/// when `low == high`, which is why the caller above never passes a zero
/// radius into the low edge without `edge` bounding it.
fn smoothstep(low: f32, high: f32, x: f32) -> f32 {
let t = ((x - low) / (high - low)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
+192 -52
View File
@@ -1,12 +1,16 @@
const RECT: u32 = 0u; const RECT: u32 = 0u;
// TEXTURE has no entry in group 1: a standalone image draws with its own
// bind group (see UiRenderNode::draw), so there is nothing per-instance left
// to look up here -- the bind group already picked the texture.
const TEXTURE: u32 = 1u; const TEXTURE: u32 = 1u;
const GLYPH: u32 = 2u;
@group(0) @binding(0) @group(0) @binding(0)
var<uniform> window: WindowUniform; var<uniform> window: WindowUniform;
@group(1) @binding(RECT) @group(1) @binding(RECT)
var<storage> rects: array<Rect>; var<storage> rects: array<Rect>;
@group(1) @binding(TEXTURE) @group(1) @binding(GLYPH)
var<storage> textures: array<TextureInfo>; var<storage> glyphs: array<GlyphInfo>;
struct Rect { struct Rect {
color: u32, color: u32,
@@ -15,14 +19,30 @@ struct Rect {
inner_radius: f32, inner_radius: f32,
} }
struct TextureInfo { struct GlyphInfo {
view_idx: u32, uv_min: vec2<f32>,
sampler_idx: u32, uv_max: vec2<f32>,
// Layer of the shared atlas array texture, not a view or bind-group
// index -- a page never gets its own bind group. See TEXTURES.md's
// "Recommended shape".
layer: u32,
color: u32,
flags: u32,
} }
/// Mirrors `Mask` in data.rs: the slot of the primitive whose coverage
/// clips this mask's subtree, and the mask it nests inside
/// (`4294967295u` at the top).
struct Mask { struct Mask {
x: UiSpan, primitive: u32,
y: UiSpan, parent: u32,
}
/// One widget's cumulative on-screen translation and the slot of the
/// ancestor to add on top of it. Mirrors `MoveOffset` in data.rs.
struct MoveOffset {
delta: vec2<f32>,
parent: u32,
} }
struct UiSpan { struct UiSpan {
@@ -35,39 +55,93 @@ struct UiScalar {
abs: f32, abs: f32,
} }
struct UiVec2 { // The shared glyph atlas: every page is one layer. Growing it recreates this
rel: vec2<f32>, // texture with headroom and copies the old layers across -- see
abs: vec2<f32>, // GpuTextures::grow_array -- rather than the binding_array<texture_2d<f32>>
} // this replaced, which needed VK_EXT_descriptor_indexing and does not survive
// a real share of Android GPUs (see TEXTURES.md).
@group(2) @binding(0) @group(2) @binding(0)
var views: binding_array<texture_2d<f32>>; var atlas: texture_2d_array<f32>;
// One standalone image's texture. The main draw (rects and glyphs) binds a
// 1x1 null texture here, since neither samples it; each image draw call
// binds its own -- see UiRenderNode::draw.
@group(2) @binding(1) @group(2) @binding(1)
var samplers: binding_array<sampler>; var image_texture: texture_2d<f32>;
@group(2) @binding(2) @group(2) @binding(2)
var samp: sampler;
// Their own group, bound once per frame rather than folded into group 2: see
// UiRenderNode::masks_layout for why an image's own bind group must not name
// either buffer.
@group(3) @binding(0)
var<storage> masks: array<Mask>; var<storage> masks: array<Mask>;
@group(3) @binding(1)
var<storage> move_offsets: array<MoveOffset>;
// Every primitive's placement, in one arena all layers share. The vertex
// stage reads the primitive it is drawing (its slot arrives as the only
// vertex attribute); the fragment stage reads a *mask's* primitive, which
// is generally a different one in a different layer. See LAYOUT.md's
// "Masks with a shape" and `Primitives` in primitive.rs.
@group(3) @binding(2)
var<storage> instances: array<PrimitiveInstance>;
// The bound on the parent walk, kept in step with `PARENT_CHAIN_LIMIT` in
// render_state.rs, which walks the identical chain on the CPU side for
// hit-testing. Bounded so a malformed chain (a cyclic `parent`) cannot
// hang the GPU -- not a claim about how deep a real tree gets. It was 16
// and that was too small: the transcript screen's composer field sits 17
// slots below the root, measured 2026-09-07 on this checkout's emulator
// by tapping it (the CPU walk's own debug assert names the chain now).
// Past the bound both walks simply stop summing, so the widget draws and
// hit-tests short by whatever the outer slots held, with nothing on
// screen to say so.
const PARENT_CHAIN_LIMIT: u32 = 64u;
/// Sums the pixel delta along the parent chain starting at `idx`, shared by
/// the vertex stage (a primitive's own corners) and the fragment stage (its
/// mask's corners) so the walk is written once. See LAYOUT.md section 2b.
fn resolve_move(idx: u32) -> vec2<f32> {
var total = vec2<f32>(0.0, 0.0);
var i = idx;
for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) {
let entry = move_offsets[i];
total += entry.delta;
if entry.parent == 4294967295u {
break;
}
i = entry.parent;
}
return total;
}
struct WindowUniform { struct WindowUniform {
dim: vec2<f32>, dim: vec2<f32>,
}; };
/// Mirrors `PrimitiveInstance` in data.rs -- the placement and what to
/// draw there. `x`/`y` are the `UiRegion`'s two spans.
struct PrimitiveInstance {
x: UiSpan,
y: UiSpan,
binding: u32,
idx: u32,
mask_idx: u32,
move_idx: u32,
}
/// A layer's draw order: one slot into `instances` per instance drawn.
struct InstanceInput { struct InstanceInput {
@location(0) x_start: vec2<f32>, @location(0) slot: u32,
@location(1) x_end: vec2<f32>,
@location(2) y_start: vec2<f32>,
@location(3) y_end: vec2<f32>,
@location(4) binding: u32,
@location(5) idx: u32,
@location(6) mask_idx: u32,
} }
struct VertexOutput { struct VertexOutput {
@location(0) top_left: vec2<f32>, @location(0) top_left: vec2<f32>,
@location(1) bot_right: vec2<f32>, @location(1) bot_right: vec2<f32>,
@location(2) uv: vec2<f32>, @location(2) uv: vec2<f32>,
@location(3) binding: u32, // `flat` is the only interpolation an integer can have, and naga
@location(4) idx: u32, // (wgpu 30) now requires saying so rather than inferring it.
@location(5) mask_idx: u32, @location(3) @interpolate(flat) binding: u32,
@location(4) @interpolate(flat) idx: u32,
@location(5) @interpolate(flat) mask_idx: u32,
@builtin(position) clip_position: vec4<f32>, @builtin(position) clip_position: vec4<f32>,
}; };
@@ -78,20 +152,38 @@ struct Region {
bot_right: vec2<f32>, bot_right: vec2<f32>,
} }
/// One primitive's on-screen corners in window pixels. Written once and
/// used by both stages: the vertex stage for the primitive it is drawing,
/// the fragment stage for a mask's -- so the shape a mask clips to and the
/// shape that was drawn cannot be computed two different ways.
struct Corners {
top_left: vec2<f32>,
bot_right: vec2<f32>,
}
fn corners_of(inst: PrimitiveInstance) -> Corners {
let top_left_rel = vec2(inst.x.start.rel, inst.y.start.rel);
let top_left_abs = vec2(inst.x.start.abs, inst.y.start.abs);
let bot_right_rel = vec2(inst.x.end.rel, inst.y.end.rel);
let bot_right_abs = vec2(inst.x.end.abs, inst.y.end.abs);
let move_delta = resolve_move(inst.move_idx);
return Corners(
floor(top_left_rel * window.dim) + floor(top_left_abs) + move_delta,
floor(bot_right_rel * window.dim) + floor(bot_right_abs) + move_delta,
);
}
@vertex @vertex
fn vs_main( fn vs_main(
@builtin(vertex_index) vi: u32, @builtin(vertex_index) vi: u32,
in: InstanceInput, in: InstanceInput,
) -> VertexOutput { ) -> VertexOutput {
var out: VertexOutput; var out: VertexOutput;
let inst = instances[in.slot];
let top_left_rel = vec2(in.x_start.x, in.y_start.x); let c = corners_of(inst);
let top_left_abs = vec2(in.x_start.y, in.y_start.y); let top_left = c.top_left;
let bot_right_rel = vec2(in.x_end.x, in.y_end.x); let bot_right = c.bot_right;
let bot_right_abs = vec2(in.x_end.y, in.y_end.y);
let top_left = floor(top_left_rel * window.dim) + floor(top_left_abs);
let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_abs);
let size = bot_right - top_left; let size = bot_right - top_left;
let uv = vec2<f32>( let uv = vec2<f32>(
@@ -101,11 +193,11 @@ fn vs_main(
let pos = (top_left + uv * size) / window.dim * 2.0 - 1.0; let pos = (top_left + uv * size) / window.dim * 2.0 - 1.0;
out.clip_position = vec4<f32>(pos.x, -pos.y, 0.0, 1.0); out.clip_position = vec4<f32>(pos.x, -pos.y, 0.0, 1.0);
out.uv = uv; out.uv = uv;
out.binding = in.binding; out.binding = inst.binding;
out.idx = in.idx; out.idx = inst.idx;
out.top_left = top_left; out.top_left = top_left;
out.bot_right = bot_right; out.bot_right = bot_right;
out.mask_idx = in.mask_idx; out.mask_idx = inst.mask_idx;
return out; return out;
} }
@@ -123,29 +215,79 @@ fn fs_main(
color = draw_rounded_rect(region, rects[i]); color = draw_rounded_rect(region, rects[i]);
} }
case TEXTURE: { case TEXTURE: {
color = draw_texture(region, textures[i]); color = draw_texture(region);
}
case GLYPH: {
color = draw_glyph(region, glyphs[i]);
} }
default: { default: {
color = vec4(1.0, 0.0, 1.0, 1.0); color = vec4(1.0, 0.0, 1.0, 1.0);
} }
} }
if in.mask_idx != 4294967295u { // Every mask on the chain, not just the innermost: a widget that set
let mask = masks[in.mask_idx]; // its own mask inside another is clipped by both, and the coverages
let tl = UiVec2(vec2(mask.x.start.rel, mask.y.start.rel), vec2(mask.x.start.abs, mask.y.start.abs)); // multiply -- so a pixel inside two feathered corners is dimmed by
let br = UiVec2(vec2(mask.x.end.rel, mask.y.end.rel), vec2(mask.x.end.abs, mask.y.end.abs)); // both, which is what a compositor does (`Mask::parent` in data.rs).
var mask_idx = in.mask_idx;
let top_left = floor(tl.rel * window.dim) + floor(tl.abs); for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) {
let bot_right = floor(br.rel * window.dim) + floor(br.abs); if mask_idx == 4294967295u {
if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y { break;
color *= 0.0;
} }
let mask = masks[mask_idx];
color.a *= mask_coverage(pos, mask);
mask_idx = mask.parent;
} }
return color; return color;
} }
// TODO: this seems really inefficient (per frag indexing)? /// How much of `pos` one mask lets through: the referenced primitive's
fn draw_texture(region: Region, info: TextureInfo) -> vec4<f32> { /// own coverage at that pixel, from the same SDF the primitive is drawn
return textureSample(views[info.view_idx], samplers[info.sampler_idx], region.uv); /// with. Nothing about the shape is copied into the mask, so a rounded
/// container's corner and its children's clipped corner are the same
/// arithmetic.
fn mask_coverage(pos: vec2<f32>, mask: Mask) -> f32 {
let inst = instances[mask.primitive];
if inst.binding != RECT {
// Unreachable: `Painter::set_mask` rejects a glyph or an image
// shape by name (see `Mask::primitive`). Letting the pixel
// through rather than reading a `rects` entry that is not there.
return 1.0;
}
let c = corners_of(inst);
return rounded_rect_coverage(pos, c.top_left, c.bot_right, rects[inst.idx].radius);
}
fn draw_texture(region: Region) -> vec4<f32> {
return textureSample(image_texture, samp, region.uv);
}
fn draw_glyph(region: Region, g: GlyphInfo) -> vec4<f32> {
let uv = mix(g.uv_min, g.uv_max, region.uv);
let texel = textureSample(atlas, samp, uv, i32(g.layer));
if (g.flags & 1u) != 0u {
return texel;
}
var color = unpack4x8unorm(g.color);
color.a *= texel.a;
return color;
}
/// The anti-aliased coverage of a rounded rect at one pixel -- the one
/// function both a drawn rect and a mask go through, and the
/// transliteration of `iris_core::rounded_rect_coverage` on the CPU,
/// which the hit test uses so a corner that cannot be tapped and a corner
/// that is not drawn are the same corner.
fn rounded_rect_coverage(
pos: vec2<f32>,
top_left: vec2<f32>,
bot_right: vec2<f32>,
radius: f32,
) -> f32 {
let edge = 0.5;
let corner = (bot_right - top_left) / 2.0;
let center = top_left + corner;
let dist = distance_from_rect(pos, center, corner, radius);
return 1.0 - smoothstep(-min(edge, radius), edge, dist);
} }
fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> { fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
@@ -153,14 +295,12 @@ fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
let edge = 0.5; let edge = 0.5;
color.a *= rounded_rect_coverage(region.pos, region.top_left, region.bot_right, rect.radius);
if rect.thickness > 0.0 {
let size = region.bot_right - region.top_left; let size = region.bot_right - region.top_left;
let corner = size / 2.0; let corner = size / 2.0;
let center = region.top_left + corner; let center = region.top_left + corner;
let dist = distance_from_rect(region.pos, center, corner, rect.radius);
color.a *= 1.0 - smoothstep(-min(edge, rect.radius), edge, dist);
if rect.thickness > 0.0 {
let dist2 = distance_from_rect(region.pos, center, corner - rect.thickness, rect.inner_radius); let dist2 = distance_from_rect(region.pos, center, corner - rect.thickness, rect.inner_radius);
color.a *= smoothstep(-min(edge, rect.inner_radius), edge, dist2); color.a *= smoothstep(-min(edge, rect.inner_radius), edge, dist2);
} }
+430 -55
View File
@@ -1,59 +1,306 @@
use image::{DynamicImage, EncodableLayout}; use image::{DynamicImage, EncodableLayout, GenericImageView};
use wgpu::{util::DeviceExt, *}; use wgpu::{util::DeviceExt, *};
use crate::{TextureUpdate, Textures}; use crate::{PatchRect, TextureKind, TextureUpdate, Textures};
use super::atlas::PAGE;
/// The fewest layers the glyph atlas array is ever created with. Two, not
/// one, for the GLES reason written on `create_array_texture`.
const MIN_ARRAY_LAYERS: u32 = 2;
/// What one texture slot is, GPU-side. Parallel to `Textures`' own slot
/// numbering (`TextureKind`'s `Image`/`Page`), so a slot's index means the
/// same thing on both sides without a second map to keep in sync.
enum Slot {
/// A slot that was freed, or pushed and freed within the same batch
/// before ever reaching here.
Empty,
Image(ImageGpu),
/// The array layer a page occupies. Pages are never freed (see
/// `Textures::free`), so this is the only variant that outlives a `Free`.
Page(u32),
}
struct ImageGpu {
/// Kept alive alongside `view`/`bind_group`, which borrow from it only in
/// the sense that dropping this drops the GPU resource they point to.
#[allow(dead_code)]
texture: Texture,
view: TextureView,
bind_group: BindGroup,
}
/// Owns the two kinds of texture iris draws:
///
/// - **The glyph atlas**, one `texture_2d_array` whose layers are pages
/// (`Slot::Page`), grown by recreating the array with headroom and
/// `copy_texture_to_texture`-ing the old layers across. No feature beyond
/// Vulkan 1.0/GLES sampling is needed for this -- a layer index is an
/// ordinary sampling operand.
/// - **Standalone images** (`Slot::Image`), each its own `Texture` and
/// `BindGroup`, drawn one `draw()` call at a time with that bind group
/// bound -- see `UiRenderNode::draw`.
///
/// See TEXTURES.md's "Recommended shape" for why, and RUST.md's
/// "iris's binding array does not survive real Android hardware" for what
/// this replaced (one giant `binding_array<texture_2d<f32>>` needing
/// `VK_EXT_descriptor_indexing`, which a real share of Android GPUs lack).
pub struct GpuTextures { pub struct GpuTextures {
device: Device, device: Device,
queue: Queue, queue: Queue,
views: Vec<TextureView>,
view_count: usize, slots: Vec<Slot>,
samplers: Vec<Sampler>,
array_texture: Texture,
array_view: TextureView,
array_capacity: u32,
/// Layers actually written. Only grows -- see `Slot::Page`.
page_count: u32,
sampler: Sampler,
/// Bound in the image slot of the main draw's bind group, which has
/// nothing of its own to put there: rects and glyphs never sample it,
/// but the layout requires something bound regardless.
null_view: TextureView, null_view: TextureView,
no_views: Vec<TextureView>,
/// Standalone-image bind groups actually built (`create_image`'s own
/// build, or one per slot touched by `rebuild_image_bind_groups`) since
/// the last `take_bind_group_creates`. IRIS_TODO.md's "many images"
/// benchmark reads this to prove the steady-state cost of an
/// unchanging image list is zero, the same way `UiRenderState`'s
/// `draw_count`/`region_mut_count` prove the layout side.
bind_group_creates: u64,
/// `grow_array` calls since the last `take_pages_grown` -- the
/// Diagnostics page's per-frame report (RUST.md's P0 box, "the first
/// input frame" investigation) reads this alongside `bind_group_creates`
/// to say whether *this* frame's glyph disappearance, if any, coincided
/// with the atlas array being recreated.
pages_grown: u64,
} }
impl GpuTextures { impl GpuTextures {
pub fn update(&mut self, textures: &mut Textures) -> bool { /// Applies queued `Textures` updates, then reports whether the *main*
let mut changed = false; /// bind group (the one rects and glyphs draw with) needs rebuilding --
/// true exactly when the atlas array was recreated (its view identity
/// changed). Pushing or freeing a standalone image never touches that
/// group: it built or drops its own. Masks/move_offsets resizing is
/// `UiRenderNode`'s own concern now (its `masks_group`, group 3) --
/// see that struct's field comment for why standalone images no longer
/// hear about either buffer at all.
pub fn update(&mut self, textures: &mut Textures, rsc_layout: &BindGroupLayout) -> bool {
let mut rebuild_main = false;
for update in textures.updates() { for update in textures.updates() {
changed = true;
match update { match update {
TextureUpdate::Push(image) => self.push(image), TextureUpdate::Push(kind, image) => {
TextureUpdate::Set(i, image) => self.set(i, image), rebuild_main |= self.push(kind, image, rsc_layout);
TextureUpdate::SetFree => self.view_count += 1, }
TextureUpdate::Set(kind, i, image) => {
rebuild_main |= self.set(kind, i, image, rsc_layout);
}
// A patch changes texture contents, not which layer or bind
// group exists, so it never asks for a rebuild -- rebuilding
// per glyph is exactly the cost this exists to avoid.
TextureUpdate::Patch(i, rect, image) => self.patch(i, rect, image),
TextureUpdate::SetFree => {}
TextureUpdate::Free(i) => self.free(i), TextureUpdate::Free(i) => self.free(i),
TextureUpdate::PushFree => self.push_free(), TextureUpdate::PushFree(_kind) => self.slots.push(Slot::Empty),
} }
} }
changed rebuild_main
}
fn set(&mut self, i: u32, image: &DynamicImage) {
self.view_count += 1;
let view = self.create_view(image);
self.views[i as usize] = view;
}
fn free(&mut self, i: u32) {
self.view_count -= 1;
self.views[i as usize] = self.null_view.clone();
}
fn push(&mut self, image: &DynamicImage) {
self.view_count += 1;
let view = self.create_view(image);
self.views.push(view);
}
fn push_free(&mut self) {
self.view_count += 1;
self.views.push(self.null_view.clone());
} }
fn create_view(&self, image: &DynamicImage) -> TextureView { fn push(
let image = image.to_rgba8(); &mut self,
let (width, height) = image.dimensions(); kind: TextureKind,
image: &DynamicImage,
rsc_layout: &BindGroupLayout,
) -> bool {
let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout);
self.slots.push(slot);
rebuilt
}
fn set(
&mut self,
kind: TextureKind,
i: u32,
image: &DynamicImage,
rsc_layout: &BindGroupLayout,
) -> bool {
let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout);
self.slots[i as usize] = slot;
rebuilt
}
fn make_slot(
&mut self,
kind: TextureKind,
image: &DynamicImage,
rsc_layout: &BindGroupLayout,
) -> (Slot, bool) {
match kind {
TextureKind::Image => {
let gpu = self.create_image(image, rsc_layout);
(Slot::Image(gpu), false)
}
TextureKind::Page { layer } => {
let mut rebuilt = false;
if layer >= self.array_capacity {
self.grow_array(rsc_layout);
rebuilt = true;
}
self.write_full_layer(layer, image);
self.page_count = self.page_count.max(layer + 1);
(Slot::Page(layer), rebuilt)
}
}
}
fn free(&mut self, i: u32) {
if let Some(slot) = self.slots.get_mut(i as usize) {
*slot = Slot::Empty;
}
// A page's layer is not reclaimed here either -- see `Slot::Page`.
}
fn patch(&mut self, i: u32, rect: PatchRect, image: &DynamicImage) {
let Some(&Slot::Page(layer)) = self.slots.get(i as usize) else {
return;
};
if rect.width == 0 || rect.height == 0 {
return;
}
// Cropped rather than written straight from the atlas, because
// write_texture wants tightly packed rows and the atlas rows are as
// wide as the atlas. A glyph is small, so the copy is too.
let sub = image
.view(rect.x, rect.y, rect.width, rect.height)
.to_image();
self.queue.write_texture(
TexelCopyTextureInfo {
texture: &self.array_texture,
mip_level: 0,
origin: Origin3d {
x: rect.x,
y: rect.y,
z: layer,
},
aspect: TextureAspect::All,
},
sub.as_bytes(),
TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(rect.width * 4),
rows_per_image: Some(rect.height),
},
Extent3d {
width: rect.width,
height: rect.height,
depth_or_array_layers: 1,
},
);
}
fn write_full_layer(&self, layer: u32, image: &DynamicImage) {
// Every page is created as exactly PAGE x PAGE (`GlyphAtlas::allocate`),
// so this is always a whole-layer write, never a crop.
let rgba = image.to_rgba8();
self.queue.write_texture(
TexelCopyTextureInfo {
texture: &self.array_texture,
mip_level: 0,
origin: Origin3d {
x: 0,
y: 0,
z: layer,
},
aspect: TextureAspect::All,
},
rgba.as_bytes(),
TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(PAGE * 4),
rows_per_image: Some(PAGE),
},
Extent3d {
width: PAGE,
height: PAGE,
depth_or_array_layers: 1,
},
);
}
/// Doubles the array's layer capacity (headroom, so this is rare) and
/// copies the old layers across GPU-side -- no readback. Recreates the
/// array's view, which invalidates every bind group that referenced it,
/// so this also rebuilds all of them before returning.
fn grow_array(&mut self, rsc_layout: &BindGroupLayout) {
self.pages_grown += 1;
let new_capacity = self.array_capacity * 2;
let new_texture = Self::create_array_texture(&self.device, new_capacity);
if self.page_count > 0 {
let mut encoder = self
.device
.create_command_encoder(&CommandEncoderDescriptor {
label: Some("atlas array grow"),
});
encoder.copy_texture_to_texture(
TexelCopyTextureInfo {
texture: &self.array_texture,
mip_level: 0,
origin: Origin3d::ZERO,
aspect: TextureAspect::All,
},
TexelCopyTextureInfo {
texture: &new_texture,
mip_level: 0,
origin: Origin3d::ZERO,
aspect: TextureAspect::All,
},
Extent3d {
width: PAGE,
height: PAGE,
depth_or_array_layers: self.page_count,
},
);
self.queue.submit(std::iter::once(encoder.finish()));
}
self.array_texture = new_texture;
self.array_view = self.array_texture.create_view(&TextureViewDescriptor {
dimension: Some(TextureViewDimension::D2Array),
..Default::default()
});
self.array_capacity = new_capacity;
self.rebuild_image_bind_groups(rsc_layout);
}
/// Called only from `grow_array`: the atlas array's view identity is the
/// one thing an image's bind group (group 2) still names that can
/// change out from under it. Masks/move_offsets resizing no longer
/// reaches here at all -- see `UiRenderNode::masks_group`.
fn rebuild_image_bind_groups(&mut self, rsc_layout: &BindGroupLayout) {
for slot in &mut self.slots {
if let Slot::Image(gpu) = slot {
gpu.bind_group = Self::make_image_bind_group(
&self.device,
rsc_layout,
&self.array_view,
&gpu.view,
&self.sampler,
);
self.bind_group_creates += 1;
}
}
}
fn create_image(&mut self, image: &DynamicImage, rsc_layout: &BindGroupLayout) -> ImageGpu {
let rgba = image.to_rgba8();
let (width, height) = rgba.dimensions();
let texture = self.device.create_texture_with_data( let texture = self.device.create_texture_with_data(
&self.queue, &self.queue,
&TextureDescriptor { &TextureDescriptor {
label: None, label: Some("image"),
size: Extent3d { size: Extent3d {
width, width,
height, height,
@@ -63,45 +310,173 @@ impl GpuTextures {
sample_count: 1, sample_count: 1,
dimension: TextureDimension::D2, dimension: TextureDimension::D2,
format: TextureFormat::Rgba8Unorm, format: TextureFormat::Rgba8Unorm,
usage: TextureUsages::TEXTURE_BINDING, usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
view_formats: &[], view_formats: &[],
}, },
wgt::TextureDataOrder::MipMajor, wgt::TextureDataOrder::MipMajor,
image.as_bytes(), rgba.as_bytes(),
); );
texture.create_view(&TextureViewDescriptor::default()) let view = texture.create_view(&TextureViewDescriptor::default());
let bind_group = Self::make_image_bind_group(
&self.device,
rsc_layout,
&self.array_view,
&view,
&self.sampler,
);
self.bind_group_creates += 1;
ImageGpu {
texture,
view,
bind_group,
}
}
/// Builds group 2 for one standalone image: the shared atlas array, this
/// image's own view and the shared sampler -- the same layout the main
/// draw uses with a null view in the image slot. Deliberately does not
/// touch masks/move_offsets (group 3, `UiRenderNode::masks_group`): see
/// that field's comment for why folding them in here was the bug.
fn make_image_bind_group(
device: &Device,
rsc_layout: &BindGroupLayout,
array_view: &TextureView,
image_view: &TextureView,
sampler: &Sampler,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout: rsc_layout,
entries: &[
BindGroupEntry {
binding: 0,
resource: BindingResource::TextureView(array_view),
},
BindGroupEntry {
binding: 1,
resource: BindingResource::TextureView(image_view),
},
BindGroupEntry {
binding: 2,
resource: BindingResource::Sampler(sampler),
},
],
label: Some("ui rsc image"),
})
}
/// The atlas is sampled as a `texture_2d_array`, and **a one-layer
/// array is not one on the GLES backend**: wgpu-hal picks the GL
/// texture target from the descriptor alone
/// (`gles::Texture::get_info_from_desc`, `(false, 1) => TEXTURE_2D`),
/// so a capacity of 1 creates a `GL_TEXTURE_2D` and binds it to the
/// shader's `sampler2DArray`. GL then treats that unit as incomplete
/// and every `textureSample` returns (0, 0, 0, 1) -- which, through
/// `draw_glyph`'s `color.a *= texel.a`, draws every glyph as a solid
/// filled box. That was iris's appearance on the emulator's GLES for
/// two days (RUST.md, "the emulator cannot draw iris's glyphs"), and
/// it is a real defect on any device whose adapter is GL rather than
/// Vulkan, not an emulator artifact. So the array never has fewer than
/// `MIN_ARRAY_LAYERS` layers; the second layer costs one page of
/// texture memory and is used by the next atlas page anyway.
fn create_array_texture(device: &Device, capacity: u32) -> Texture {
debug_assert!(
capacity >= MIN_ARRAY_LAYERS,
"glyph atlas array asked for {capacity} layers; fewer than {MIN_ARRAY_LAYERS} is a \
GL_TEXTURE_2D on the GLES backend and draws every glyph as a box"
);
device.create_texture(&TextureDescriptor {
label: Some("glyph atlas array"),
size: Extent3d {
width: PAGE,
height: PAGE,
depth_or_array_layers: capacity,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: TextureFormat::Rgba8Unorm,
usage: TextureUsages::TEXTURE_BINDING
| TextureUsages::COPY_DST
| TextureUsages::COPY_SRC,
view_formats: &[],
})
} }
pub fn new(device: &Device, queue: &Queue) -> Self { pub fn new(device: &Device, queue: &Queue) -> Self {
let sampler = default_sampler(device);
let null_view = null_texture_view(device); let null_view = null_texture_view(device);
let array_capacity = MIN_ARRAY_LAYERS;
let array_texture = Self::create_array_texture(device, array_capacity);
let array_view = array_texture.create_view(&TextureViewDescriptor {
dimension: Some(TextureViewDimension::D2Array),
..Default::default()
});
Self { Self {
device: device.clone(), device: device.clone(),
queue: queue.clone(), queue: queue.clone(),
views: Vec::new(), slots: Vec::new(),
samplers: vec![default_sampler(device)], array_texture,
no_views: vec![null_view.clone()], array_view,
array_capacity,
page_count: 0,
sampler,
null_view, null_view,
view_count: 0, bind_group_creates: 0,
pages_grown: 0,
} }
} }
pub fn views(&self) -> Vec<&TextureView> { /// Reads and zeroes the standalone-image bind-group creation counter --
if self.views.is_empty() { /// call once per frame before `update()`, mirroring
&self.no_views /// `UiRenderState::take_counters`.
} else { pub fn take_bind_group_creates(&mut self) -> u64 {
&self.views std::mem::take(&mut self.bind_group_creates)
}
.iter()
.by_ref()
.collect()
} }
pub fn samplers(&self) -> Vec<&Sampler> { /// Reads and zeroes the atlas-array-grow counter -- see `pages_grown`'s
self.samplers.iter().by_ref().collect() /// field comment.
pub fn take_pages_grown(&mut self) -> u64 {
std::mem::take(&mut self.pages_grown)
}
pub fn array_view(&self) -> &TextureView {
&self.array_view
}
pub fn null_view(&self) -> &TextureView {
&self.null_view
}
pub fn sampler(&self) -> &Sampler {
&self.sampler
}
/// The bind group a standalone image draws with. Panics if `idx` names an
/// atlas page or a freed slot instead -- either is a caller bug (the
/// wrong kind of instance reached this draw path), not a condition to
/// recover from.
pub fn image_bind_group(&self, idx: u32) -> &BindGroup {
match self.slots.get(idx as usize) {
Some(Slot::Image(gpu)) => &gpu.bind_group,
other => panic!("texture slot {idx} is not a live standalone image: {other:?}"),
}
} }
pub fn view_count(&self) -> usize { pub fn view_count(&self) -> usize {
self.view_count self.slots
.iter()
.filter(|s| !matches!(s, Slot::Empty))
.count()
}
}
impl std::fmt::Debug for Slot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Slot::Empty => write!(f, "Empty"),
Slot::Image(_) => write!(f, "Image"),
Slot::Page(layer) => write!(f, "Page(layer={layer})"),
}
} }
} }
+7 -2
View File
@@ -21,13 +21,18 @@ impl<T: Pod> ArrBuf<T> {
_pd: PhantomData, _pd: PhantomData,
} }
} }
pub fn update(&mut self, device: &Device, queue: &Queue, data: &[T]) { /// Returns whether the underlying `Buffer` was recreated -- a caller that
if self.len != data.len() { /// cached a `BindGroup` referencing it (as `GpuTextures` does for the
/// masks buffer) needs to know to rebuild that too.
pub fn update(&mut self, device: &Device, queue: &Queue, data: &[T]) -> bool {
let resized = self.len != data.len();
if resized {
self.len = data.len(); self.len = data.len();
self.buffer = self.buffer =
Self::init_buf(device, std::mem::size_of_val(data), self.usage, self.label); Self::init_buf(device, std::mem::size_of_val(data), self.usage, self.label);
} }
queue.write_buffer(&self.buffer, 0, bytemuck::cast_slice(data)); queue.write_buffer(&self.buffer, 0, bytemuck::cast_slice(data));
resized
} }
fn init_buf(device: &Device, size: usize, usage: BufferUsages, label: &'static str) -> Buffer { fn init_buf(device: &Device, size: usize, usage: BufferUsages, label: &'static str) -> Buffer {
let mut size = size as u64; let mut size = size as u64;
+152
View File
@@ -0,0 +1,152 @@
//! I4 (RUST.md): an AccessKit tree built from iris's own widget tree,
//! shared by both backends -- `android/view.rs` pushes its `TreeUpdate`s
//! through `accesskit_android::Adapter`, `default/mod.rs` through
//! `accesskit_winit::Adapter`. Kept modular the way input's sense registry
//! is: `Widgets::named()` is a side set populated only by `.label()`, so a
//! widget nobody named is never visited here at all, not even to decide it
//! has no name.
//!
//! The tree itself is deliberately flat -- one synthetic `Role::Window`
//! root with every named widget as a direct child, in no particular order.
//! iris's actual widget nesting (a label three `Span`s deep inside a
//! `ScrollArea`) carries no accessibility meaning of its own here: nothing
//! upstream of a named leaf needs a node, since a screen reader's own
//! traversal (and uiautomator's tap-by-name, the pass condition this was
//! built for) works from each node's on-screen bounds rather than from
//! tree structure. Mirroring the real widget tree exactly would also mean
//! rebuilding intermediate nodes whenever *any* container above a named
//! widget resizes, which is most frames -- the flat shape is what keeps
//! rebuilds tied to "a name, a role or a position actually changed".
use crate::{PixelRegion, UiRenderState, UiRsc, WidgetId, Widgets, util::HashMap};
use accesskit::{Node, NodeId, Rect, Role, TreeId, TreeInfo, TreeUpdate};
/// Reserved for the synthetic root; every real widget's `SlotId::as_u64`
/// starts at 1, so this can never collide with one (see that method's
/// doc comment).
const WINDOW_NODE: NodeId = NodeId(0);
fn node_id(id: WidgetId) -> NodeId {
NodeId(id.as_u64())
}
#[derive(Clone, PartialEq)]
struct Entry {
name: String,
role: Role,
bounds: PixelRegion,
}
fn entry_node(entry: &Entry) -> Node {
let mut node = Node::new(entry.role);
node.set_label(entry.name.clone());
node.set_bounds(Rect {
x0: entry.bounds.top_left.x as f64,
y0: entry.bounds.top_left.y as f64,
x1: entry.bounds.bot_right.x as f64,
y1: entry.bounds.bot_right.y as f64,
});
node
}
/// Owns the last tree pushed out, so `update` can tell "nothing
/// accessibility-relevant changed" from "something did" without asking
/// the platform adapter to diff two `Node`s itself. One of these per
/// window/view -- `default::DefaultUiState` and `android::AndroidUiState`
/// each keep one.
#[derive(Default)]
pub struct AccessTree {
known: HashMap<WidgetId, Entry>,
/// `TreeUpdate`s actually produced since the last `take_rebuilds` --
/// the AccessKit-tree twin of `UiRenderState::take_counters`. Should
/// stay at 0 across an unchanged frame and move by exactly 1 when a
/// named widget's position, name or role changes, however many other
/// widgets are on screen; see `iris/src/access_tests.rs`.
rebuilds: u64,
}
impl AccessTree {
pub fn new() -> Self {
Self::default()
}
fn collect(
widgets: &Widgets,
render: &UiRenderState,
rsc: &dyn UiRsc,
) -> HashMap<WidgetId, Entry> {
let mut current = HashMap::default();
for id in widgets.named() {
let Some(bounds) = render.window_region(&id, rsc) else {
continue;
};
let Some(widget) = widgets.get_dyn(id) else {
continue;
};
current.insert(
id,
Entry {
name: widgets.label(id).clone(),
role: widget.access_role(),
bounds,
},
);
}
current
}
/// Walks `widgets.named()`, looks up each one's current screen bounds
/// via `render.window_region` (which resolves the same move-chain
/// `resolved_region` does, so a moved subtree reports where it
/// actually is), and returns a full `TreeUpdate` if and only if that
/// set differs from the last call -- added, removed, renamed, or
/// moved/resized. A widget that is named but not currently active
/// (not drawn this frame) is left out, the same as one never named at
/// all.
pub fn update(
&mut self,
widgets: &Widgets,
render: &UiRenderState,
rsc: &dyn UiRsc,
) -> Option<TreeUpdate> {
let current = Self::collect(widgets, render, rsc);
if current == self.known {
return None;
}
self.known = current.clone();
self.rebuilds += 1;
Some(build_update(&current))
}
/// The unconditional twin of `update`, for a platform adapter's
/// activation handler (`android/access.rs`'s `AndroidAccessSource`) --
/// AccessKit asks for a full tree the first time a client attaches,
/// which is exactly the case `update`'s diff-against-`known` is not
/// meant to answer (it may have already sent this same snapshot to a
/// client that has since detached and reattached).
pub fn build_full(widgets: &Widgets, render: &UiRenderState, rsc: &dyn UiRsc) -> TreeUpdate {
build_update(&Self::collect(widgets, render, rsc))
}
/// Reads and zeroes the rebuild counter, the same call shape as
/// `UiRenderState::take_counters`.
pub fn take_rebuilds(&mut self) -> u64 {
std::mem::take(&mut self.rebuilds)
}
}
fn build_update(current: &HashMap<WidgetId, Entry>) -> TreeUpdate {
let mut window = Node::new(Role::Window);
let mut nodes = Vec::with_capacity(current.len() + 1);
for (&id, entry) in current {
window.push_child(node_id(id));
nodes.push((node_id(id), entry_node(entry)));
}
nodes.push((WINDOW_NODE, window));
TreeUpdate {
nodes,
tree: Some(TreeInfo::new(WINDOW_NODE)),
tree_id: TreeId::ROOT,
focus: WINDOW_NODE,
}
}
+56 -1
View File
@@ -1,4 +1,6 @@
use crate::{LayerId, MaskIdx, PrimitiveHandle, TextureHandle, UiRegion, WidgetId}; use crate::{
LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId, util::Vec2,
};
/// important non rendering data for retained drawing /// important non rendering data for retained drawing
#[derive(Debug)] #[derive(Debug)]
@@ -9,6 +11,59 @@ pub struct ActiveData {
pub textures: Vec<TextureHandle>, pub textures: Vec<TextureHandle>,
pub primitives: Vec<PrimitiveHandle>, pub primitives: Vec<PrimitiveHandle>,
pub children: Vec<WidgetId>, pub children: Vec<WidgetId>,
/// The mask this widget was drawn **under** (its parent's), not the
/// one it set for itself -- see `own_mask` for that.
pub mask: MaskIdx, pub mask: MaskIdx,
/// The mask slot this widget allocated for *itself* with
/// `Painter::set_mask`, or `MaskIdx::NONE`. Kept across redraws and
/// rewritten in place, the way `move_slot` is: a `Masked` that pushed
/// a fresh slot each draw left every already-drawn descendant --
/// which `draw_inner`'s unchanged-region fast path does not revisit --
/// clipping to the *old* slot's region, so a composer whose bar had
/// since been placed at the bottom of the screen was still being
/// clipped to a box at the top of it and drew nothing (measured
/// 2026-09-06: four mask entries live, none of them the widget's
/// current region). Its path out is the `undraw` branch of
/// `UiRenderState::remove`, which drops the self-ownership ref taken
/// when the slot was allocated.
pub own_mask: MaskIdx,
pub layer: LayerId, pub layer: LayerId,
/// What `Widget::draw` returned the last time this widget was actually
/// drawn -- read by a parent placing this widget again without
/// redrawing it, replacing `Cache.size`'s old role. See LAYOUT.md
/// section 5.
pub size: Size,
/// This widget's slot in `UiData::move_offsets`, assigned on its first
/// draw and kept for the rest of its life (redraws reuse it in place
/// so a retained child's `parent` link never goes stale). See
/// LAYOUT.md section 2.
pub move_slot: MoveIdx,
/// How much of this widget's own `move_slot` delta is already folded
/// into `region` above, in window pixels. The two mechanisms that
/// write that slot disagree about this and cannot be told apart from
/// the slot alone: `UiRenderState::mov` shifts `region` and the delta
/// together (the *offered* region genuinely moved), while
/// `Painter::reposition` writes only the delta (`region` stays the
/// offered box and the delta says where inside it the content was
/// placed). So anything that wants the widget's real position --
/// `resolved_region`, and through it every hit test -- must subtract
/// this from the chain sum. Without it a panned widget's own hit box
/// sits at twice the pan while its descendants' are correct, which is
/// how it went unnoticed: the composer's field became untappable
/// after a finger pan (2026-09-06). Reset to zero whenever the widget
/// is really redrawn, since `draw_inner` zeroes the slot then too.
pub move_applied: Vec2,
/// The offset the last `Painter::reposition` placed this widget's
/// content at *within* `region`, in window pixels. The move slot has
/// exactly one owner and one meaning:
/// `move_offsets[move_slot] == move_applied + repositioned`. `mov`
/// adds to the first, `reposition` overwrites the second (it
/// recomputes `from` afresh every call, so repeating it must land on
/// the same answer rather than drifting), and both then rewrite the
/// slot from the sum -- which is what lets a parent both move a child
/// with its own layout and place it inside that moved region in one
/// frame. `LazySpan::place`'s Bottom-known branch does exactly that once a
/// row's blocks wrap. Reset to zero on a real redraw, with
/// `move_applied` and the slot itself.
pub repositioned: Vec2,
} }
-18
View File
@@ -1,18 +0,0 @@
use crate::{BothAxis, Len, UiVec2, WidgetId, util::HashMap};
#[derive(Default)]
pub struct Cache {
pub size: BothAxis<HashMap<WidgetId, (UiVec2, Len)>>,
}
impl Cache {
pub fn remove(&mut self, id: WidgetId) {
self.size.x.remove(&id);
self.size.y.remove(&id);
}
pub fn clear(&mut self) {
self.size.x.clear();
self.size.y.clear();
}
}
+51 -4
View File
@@ -1,15 +1,16 @@
use crate::{Mask, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena}; use crate::{
Mask, MoveOffset, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena,
};
mod access;
mod active; mod active;
mod cache;
mod painter; mod painter;
mod render_state; mod render_state;
mod size;
pub use access::*;
pub use active::*; pub use active::*;
pub use painter::Painter; pub use painter::Painter;
pub use render_state::*; pub use render_state::*;
pub use size::*;
#[derive(Default)] #[derive(Default)]
pub struct UiData { pub struct UiData {
@@ -17,6 +18,52 @@ pub struct UiData {
pub textures: Textures, pub textures: Textures,
pub text: TextData, pub text: TextData,
pub masks: TrackedArena<Mask, u32>, pub masks: TrackedArena<Mask, u32>,
/// One entry per widget ever drawn, forming the parent-linked chain
/// `resolve_move` walks in both shader stages. Allocated once on a
/// widget's first draw and reused for every later redraw of the same
/// id (never reallocated), so a retained descendant's `parent` index
/// never goes stale -- see LAYOUT.md section 2.
pub move_offsets: TrackedArena<MoveOffset, u32>,
/// Every widget whose [`crate::Widget::tick`] should run before the
/// next frame -- today, a `LazySpan` coasting through a fling. Added by
/// [`Self::animate`] when the animation starts and removed by
/// [`Self::tick_animations`] the frame its `tick` answers `false`, so
/// a stopped animation costs nothing and a dropped widget cannot be
/// ticked (`get_dyn_mut` answers `None` and it is dropped the same
/// way).
animating: Vec<WidgetId>,
}
impl UiData {
/// Ask for `id`'s [`crate::Widget::tick`] to run every frame until it
/// says it is done. Idempotent -- registering an already-animating
/// widget is the ordinary case (a second fling before the first
/// settled) and must not tick it twice per frame.
pub fn animate(&mut self, id: WidgetId) {
if !self.animating.contains(&id) {
self.animating.push(id);
}
}
/// Tick every registered widget to `now`, drop the ones that finished,
/// and say whether any is still going -- which is a backend's cue to
/// ask for another frame. Called once per frame *before* the draw, so
/// what the frame draws is this instant's position rather than the
/// previous one's.
pub fn tick_animations(&mut self, now: std::time::Instant) -> bool {
// Taken out and put back rather than iterated in place: `tick`
// needs `&mut` on the widget arena this list lives beside, and a
// widget is free to register another one while ticking.
let mut registered = std::mem::take(&mut self.animating);
registered.retain(|&id| match self.widgets.get_dyn_mut(id) {
Some(widget) => widget.tick(now),
None => false,
});
for id in registered {
self.animate(id);
}
!self.animating.is_empty()
}
} }
pub trait UiRsc { pub trait UiRsc {
+283 -33
View File
@@ -1,7 +1,10 @@
use crate::{ use crate::{
Axis, Len, RenderedText, Size, SizeCtx, StrongWidget, TextAttrs, TextBuffer, TextData, Color, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle,
TextureHandle, UiRegion, UiRenderState, UiRsc, Widget, WidgetId, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
render::{Mask, MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst}, render::{
Drawn, GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst,
RectPrimitive,
},
util::Vec2, util::Vec2,
}; };
@@ -12,6 +15,11 @@ pub struct Painter<'a> {
pub(super) region: UiRegion, pub(super) region: UiRegion,
pub(super) mask: MaskIdx, pub(super) mask: MaskIdx,
pub(super) move_slot: MoveIdx,
/// This widget's own mask slot, reused across redraws -- see
/// `ActiveData::own_mask`. `MaskIdx::NONE` until `set_mask` is called
/// for the first time in this widget's life.
pub(super) own_mask: MaskIdx,
pub(super) textures: Vec<TextureHandle>, pub(super) textures: Vec<TextureHandle>,
pub(super) primitives: Vec<PrimitiveHandle>, pub(super) primitives: Vec<PrimitiveHandle>,
pub(super) children: Vec<WidgetId>, pub(super) children: Vec<WidgetId>,
@@ -21,19 +29,49 @@ pub struct Painter<'a> {
impl<'a> Painter<'a> { impl<'a> Painter<'a> {
fn primitive_at<P: Primitive>(&mut self, primitive: P, region: UiRegion) { fn primitive_at<P: Primitive>(&mut self, primitive: P, region: UiRegion) {
let h = self.state.layers.write( self.write_primitive(primitive, region, Drawn::Yes);
}
/// The one path every primitive this widget owns goes through --
/// drawn or, for a mask's shape, only referenced.
fn write_primitive<P: Primitive>(
&mut self,
primitive: P,
region: UiRegion,
drawn: Drawn,
) -> u32 {
let h = self.state.write_primitive(
self.layer, self.layer,
drawn,
PrimitiveInst { PrimitiveInst {
id: self.id, id: self.id,
primitive, primitive,
region, region,
mask_idx: self.mask, mask_idx: self.mask,
move_idx: self.move_slot,
}, },
); );
if self.mask != MaskIdx::NONE { if self.mask != MaskIdx::NONE {
// TODO: I have no clue if this works at all :joy: // TODO: I have no clue if this works at all :joy:
self.rsc.ui_mut().masks.push_ref(self.mask); self.rsc.ui_mut().masks.push_ref(self.mask);
} }
let slot = h.slot;
self.own(h);
slot
}
/// Take ownership of a handle this widget just wrote.
///
/// The one place a `PrimitiveHandle` enters `self.primitives`, and so
/// the one place that can keep `Primitives::handle_index` in step with
/// where it lands -- which is what `UiRenderState::apply_free` reads
/// instead of scanning this vec. Anything that writes a primitive
/// without coming through here leaves that index unset, and its
/// position in a layer's draw order stops being renumbered.
fn own(&mut self, h: PrimitiveHandle) {
self.state
.primitives
.set_handle_index(h.slot, self.primitives.len() as u32);
self.primitives.push(h); self.primitives.push(h);
} }
@@ -46,75 +84,291 @@ impl<'a> Painter<'a> {
self.primitive_at(primitive, region.within(&self.region)); self.primitive_at(primitive, region.within(&self.region));
} }
/// Clip everything this widget draws, itself and its descendants, to
/// `region`. One call per widget; a widget drawn inside another
/// widget's mask nests instead -- the new mask chains to the inherited
/// one (`Mask::parent`) and the fragment stage multiplies both
/// coverages, which is what lets a transcript row's code fence clip
/// to itself *and* to the list it scrolls inside.
///
/// The clip is a **primitive**, not a rectangle copied into the mask:
/// this writes an undrawn `RectPrimitive` at `region` and points the
/// mask at it, so the fragment stage evaluates the same rounded-rect
/// coverage a drawn rect gets. See LAYOUT.md's "Masks with a shape".
///
/// The slot is allocated once and **rewritten in place** on every
/// later draw rather than pushed again, because a descendant whose own
/// region did not change is not redrawn (`draw_inner`'s fast path) and
/// so keeps pointing at whichever slot it was drawn under. See
/// `ActiveData::own_mask` for what pushing a fresh one cost.
pub fn set_mask(&mut self, region: UiRegion) { pub fn set_mask(&mut self, region: UiRegion) {
assert!(self.mask == MaskIdx::NONE); let shape = self.write_primitive(RectPrimitive::color(Color::NONE), region, Drawn::No);
self.mask = self.rsc.ui_mut().masks.push(Mask { region }); self.set_mask_to(shape);
} }
/// Draws a widget within this widget's region. /// Clip everything this widget draws after this call to `shape`'s
pub fn widget<W: ?Sized>(&mut self, id: &StrongWidget<W>) { /// own shape -- the first primitive `shape`'s subtree drew, which
self.widget_at(id, self.region); /// must already have been drawn this frame
/// (`UiRenderState::first_primitive`). What `.masked_by()` uses to
/// clip a container's content to the rounded background it draws,
/// with no radius argument anywhere that could fall out of step with
/// the one being drawn.
pub fn set_mask_to_widget<W: ?Sized>(&mut self, shape: &StrongWidget<W>) {
let slot = self.state.first_primitive(shape.id()).unwrap_or_else(|| {
panic!(
"'{}' was given as a mask's shape but drew no primitive, so there is nothing to \
clip to",
self.rsc.widgets().label(shape.id()),
)
});
self.set_mask_to(slot);
}
/// Points this widget's mask at a primitive that has already been
/// written -- the shared half of [`Self::set_mask`].
fn set_mask_to(&mut self, shape: u32) {
// `assert!`, not `debug_assert!`: one comparison per widget draw,
// and the second call silently *replacing* the first is a widget
// drawn unclipped -- which reaches the screen and nothing says so.
// Every build anybody runs here is release
// (docs/REVIEW-2026-09-07.md's R1).
assert!(
self.own_mask == MaskIdx::NONE || self.mask != self.own_mask,
"set_mask called twice while drawing one widget: the second would replace the first \
rather than nest inside it",
);
// A glyph would need a CPU-side alpha plane for the hit test to
// agree with the shader, and a standalone image a bind-group
// switch the fragment stage cannot make -- see `Mask::primitive`.
// Named here rather than left to the shader, which would read a
// rect that is not there and clip to nothing.
let binding = self.state.primitives.instance(shape).binding;
assert_eq!(
binding,
RectPrimitive::BINDING,
"a mask's shape must be a rect primitive; primitive {shape} is binding {binding}",
);
let parent = self.mask;
let mask = Mask {
primitive: shape,
parent,
};
let old_parent = if self.own_mask == MaskIdx::NONE {
let slot = self.rsc.ui_mut().masks.push(mask);
// The one ref this widget holds on its own slot, so the slot
// outlives any single frame's primitives; released in
// `UiRenderState::remove`'s `undraw` branch.
self.rsc.ui_mut().masks.push_ref(slot);
self.own_mask = slot;
MaskIdx::NONE
} else {
let old = self.rsc.ui().masks[self.own_mask.idx()].parent;
*self.rsc.ui_mut().masks.get_mut(self.own_mask) = mask;
old
};
// The chain link's own ref, taken before the old one is dropped so
// that re-chaining to the same slot cannot free it in between.
// Released here when the link changes, and in
// `UiRenderState::remove` when this widget's slot goes.
if old_parent != parent {
if parent != MaskIdx::NONE {
self.rsc.ui_mut().masks.push_ref(parent);
}
if old_parent != MaskIdx::NONE {
self.rsc.ui_mut().masks.remove(old_parent);
}
}
self.mask = self.own_mask;
}
/// Draws a widget within this widget's region, returning the size it
/// reported using.
pub fn widget<W: ?Sized>(&mut self, id: &StrongWidget<W>) -> Size {
self.widget_at(id, self.region)
} }
/// Draws a widget somewhere within this one. /// Draws a widget somewhere within this one.
/// Useful for drawing child widgets in select areas. /// Useful for drawing child widgets in select areas.
pub fn widget_within<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) { pub fn widget_within<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) -> Size {
self.widget_at(id, region.within(&self.region)); self.widget_at(id, region.within(&self.region))
} }
fn widget_at<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) { fn widget_at<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) -> Size {
self.children.push(id.id()); self.children.push(id.id());
// Passed directly rather than looked up from `self.active`: this
// widget's own `ActiveData` (which would carry its `move_slot`) is
// not inserted there until *after* its own `Widget::draw` returns,
// so a lookup here -- for a child drawn partway through that same
// call -- would always find nothing. `self.move_slot` is this
// widget's own slot, already known, and always correct regardless
// of insertion order. See `UiRenderState::move_parent_of`.
self.state.draw_inner( self.state.draw_inner(
self.layer, self.layer,
id.id(), id.id(),
region, region,
Some(self.id), Some(self.id),
self.move_slot.idx() as u32,
self.mask, self.mask,
None, None,
None,
crate::render::MaskIdx::NONE,
self.rsc, self.rsc,
); );
self.state
.active
.get(&id.id())
.map(|a| a.size)
.unwrap_or_default()
}
/// Move an already-drawn child from wherever it currently sits to
/// `region` (resolved against this widget's own region, matching
/// `widget_within`) without a second draw -- an O(1) offset write via
/// `UiRenderState::mov`. For a container that draws a child
/// provisionally to learn its size (e.g. `Aligned`) and then places it
/// for real. Only valid when the target keeps the child's drawn size;
/// if the shape actually changes, the normal `widget_within` dispatch
/// (which detects that from the stored region) does the right thing
/// instead.
pub fn reposition<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) {
let region = region.within(&self.region);
self.state.reposition(id.id(), region, self.rsc);
}
/// Draw `child` at a provisional region to learn its size under one
/// axis's worth of assumption, discard everything it wrote, then draw
/// it again at the region that assumption produced. For the rare
/// parent that cannot pick an offered size without already knowing the
/// answer. Twice the cost of one `draw`; every other case in this file
/// avoids it.
pub fn draw_twice<W: ?Sized>(
&mut self,
id: &StrongWidget<W>,
first: UiRegion,
second: impl FnOnce(Size) -> UiRegion,
) -> Size {
let used = self.widget_within(id, first);
let region = second(used);
self.widget_within(id, region)
} }
pub fn texture_within(&mut self, handle: &TextureHandle, region: UiRegion) { pub fn texture_within(&mut self, handle: &TextureHandle, region: UiRegion) {
self.textures.push(handle.clone()); self.textures.push(handle.clone());
self.primitive_at(handle.primitive(), region.within(&self.region)); self.write_image(handle.image_index(), region.within(&self.region));
} }
pub fn texture(&mut self, handle: &TextureHandle) { pub fn texture(&mut self, handle: &TextureHandle) {
self.textures.push(handle.clone()); self.textures.push(handle.clone());
self.primitive(handle.primitive()); self.write_image(handle.image_index(), self.region);
} }
pub fn texture_at(&mut self, handle: &TextureHandle, region: UiRegion) { pub fn texture_at(&mut self, handle: &TextureHandle, region: UiRegion) {
self.textures.push(handle.clone()); self.textures.push(handle.clone());
self.primitive_at(handle.primitive(), region); self.write_image(handle.image_index(), region);
} }
/// returns (handle, offset from top left) /// A standalone image draws with its own bind group rather than sharing
pub fn render_text(&mut self, buffer: &mut TextBuffer, attrs: &TextAttrs) -> RenderedText { /// the layer's one instanced draw, so it goes through
/// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`.
fn write_image(&mut self, texture_idx: u32, region: UiRegion) {
let h = self.state.write_image(
self.layer,
self.id,
texture_idx,
region,
self.mask,
self.move_slot,
);
if self.mask != MaskIdx::NONE {
self.rsc.ui_mut().masks.push_ref(self.mask);
}
self.own(h);
}
pub fn render_text(
&mut self,
buffer: &mut TextBuffer,
attrs: &TextAttrs,
width: Option<f32>,
) -> RenderedText {
let density = self.state.density;
// Counted here rather than in `TextView::render`, which returns
// its memoized layout without reaching this -- so this counts
// shapes, not requests. `UiRenderState::take_counters`.
self.state.shape_count += 1;
let ui = self.rsc.ui_mut(); let ui = self.rsc.ui_mut();
ui.text.draw(buffer, attrs, &mut ui.textures) ui.text
.render(buffer, attrs, width, &mut ui.textures, density)
}
/// Which glyph atlas the glyphs handed out right now belong to --
/// what a widget caching a [`RenderedText`] across frames has to
/// compare against before re-emitting it (`GlyphAtlas::clear`).
pub fn atlas_generation(&mut self) -> u64 {
self.rsc.ui_mut().text.atlas.generation()
}
/// Draw a laid-out string: one quad per glyph, all sampling the atlas.
///
/// `origin` is where the text's top-left goes; every glyph is placed at an
/// absolute pixel offset from it, so re-drawing after a resize is this loop
/// and nothing else.
pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) {
// A caller re-emitting quads placed against an atlas that has since
// been cleared draws every glyph from coordinates now holding
// something else. Caught at the submission rather than on screen,
// where it reads as fragments of unrelated letters. `assert_eq!`
// for R1's reason: two integers per laid-out string, not per
// glyph, and the failure is unreadable text on a release build.
assert_eq!(
text.generation,
self.atlas_generation(),
"glyphs placed against atlas generation {} submitted against {}: the holder did not \
re-render after the atlas was cleared",
text.generation,
self.atlas_generation(),
);
let flags_for = |is_color| {
if is_color {
GlyphPrimitive::IS_COLOR
} else {
0
}
};
for glyph in text.glyphs.iter() {
let mut region = origin;
region.x.end = region.x.start;
region.y.end = region.y.start;
let mut region = region.offset(UiVec2::abs(glyph.offset));
region.x.end = region.x.start + UiScalar::abs(glyph.entry.width as f32);
region.y.end = region.y.start + UiScalar::abs(glyph.entry.height as f32);
self.primitive_at(
GlyphPrimitive::new(
glyph.entry.uv_min,
glyph.entry.uv_max,
glyph.entry.layer,
glyph.color,
flags_for(glyph.entry.is_color),
),
region,
);
}
} }
pub fn region(&self) -> UiRegion { pub fn region(&self) -> UiRegion {
self.region self.region
} }
pub fn size<W: ?Sized + Widget>(&mut self, id: &StrongWidget<W>) -> Size {
self.size_ctx().size(id)
}
pub fn len_axis<W: ?Sized + Widget>(&mut self, id: &StrongWidget<W>, axis: Axis) -> Len {
match axis {
Axis::X => self.size_ctx().width(id),
Axis::Y => self.size_ctx().height(id),
}
}
pub fn output_size(&self) -> Vec2 { pub fn output_size(&self) -> Vec2 {
self.state.output_size self.state.output_size
} }
/// Physical pixels per `dp` -- see `UiRenderState::density`'s field
/// doc. What `Len::dp`'s `apply_rest` call resolves against.
pub fn density(&self) -> f32 {
self.state.density
}
pub fn px_size(&mut self) -> Vec2 { pub fn px_size(&mut self) -> Vec2 {
self.region.size().to_abs(self.state.output_size) self.region.size().to_abs(self.state.output_size)
} }
@@ -138,8 +392,4 @@ impl<'a> Painter<'a> {
pub fn id(&self) -> &WidgetId { pub fn id(&self) -> &WidgetId {
&self.id &self.id
} }
pub fn size_ctx(&mut self) -> SizeCtx<'_> {
self.state.size_ctx(self.id, self.region.size(), self.rsc)
}
} }
File diff suppressed because it is too large. Load diff
-86
View File
@@ -1,86 +0,0 @@
use crate::{
Axis, AxisT, IdLike, Len, RenderedText, Size, TextAttrs, TextBuffer, TextData, Textures,
UiVec2, WidgetAxisFns, WidgetId, Widgets, XAxis, YAxis, ui::cache::Cache, util::Vec2,
};
pub struct SizeCtx<'a> {
pub text: &'a mut TextData,
pub textures: &'a mut Textures,
pub(super) source: WidgetId,
pub(super) widgets: &'a Widgets,
pub(super) cache: &'a mut Cache,
/// TODO: should this be pub? rn used for sized
pub outer: UiVec2,
pub(super) output_size: Vec2,
pub(super) id: WidgetId,
}
impl SizeCtx<'_> {
pub fn id(&self) -> &WidgetId {
&self.id
}
pub fn source(&self) -> &WidgetId {
&self.source
}
pub(super) fn len_inner<A: const AxisT>(&mut self, id: WidgetId) -> Len {
if let Some((_, len)) = self.cache.size.axis::<A>().get(&id) {
return *len;
}
let len = self
.widgets
.get_dyn_dynamic(id)
.desired_len::<A>(&mut SizeCtx {
text: self.text,
textures: self.textures,
source: self.source,
widgets: self.widgets,
cache: self.cache,
outer: self.outer,
output_size: self.output_size,
id,
});
self.cache.size.axis::<A>().insert(id, (self.outer, len));
len
}
pub fn width(&mut self, id: impl IdLike) -> Len {
self.len_inner::<XAxis>(id.id())
}
pub fn height(&mut self, id: impl IdLike) -> Len {
self.len_inner::<YAxis>(id.id())
}
pub fn len_axis(&mut self, id: impl IdLike, axis: Axis) -> Len {
match axis {
Axis::X => self.width(id),
Axis::Y => self.height(id),
}
}
pub fn size(&mut self, id: impl IdLike) -> Size {
let id = id.id();
Size {
x: self.width(id),
y: self.height(id),
}
}
pub fn px_size(&mut self) -> Vec2 {
self.outer.to_abs(self.output_size)
}
pub fn output_size(&mut self) -> Vec2 {
self.output_size
}
pub fn draw_text(&mut self, buffer: &mut TextBuffer, attrs: &TextAttrs) -> RenderedText {
self.text.draw(buffer, attrs, self.textures)
}
pub fn label(&self, id: WidgetId) -> &String {
self.widgets.label(id)
}
}
+9
View File
@@ -71,6 +71,15 @@ impl<T, I: IdNum> TrackedArena<T, I> {
self.refs[i.idx()] += 1; self.refs[i.idx()] += 1;
} }
/// Mutable access to an existing entry, for the rare case (the move
/// offset chain) where an already-allocated slot is updated in place
/// rather than replaced. Marks the arena changed so the GPU copy is
/// re-uploaded.
pub fn get_mut(&mut self, id: Id<I>) -> &mut T {
self.changed = true;
&mut self.inner.data[id.idx()]
}
pub fn remove(&mut self, id: Id<I>) -> T pub fn remove(&mut self, id: Id<I>) -> T
where where
T: Copy, T: Copy,
+9 -8
View File
@@ -9,15 +9,16 @@ pub const trait DivOr {
fn div_or(self, rhs: Self, other: Self) -> Self; fn div_or(self, rhs: Self, other: Self) -> Self;
} }
impl const DivOr for f32 { const impl DivOr for f32 {
fn div_or(self, rhs: Self, other: Self) -> Self { fn div_or(self, rhs: Self, other: Self) -> Self {
let res = self / rhs; let res = self / rhs;
if res.is_nan() { other } else { res } if res.is_nan() { other } else { res }
} }
} }
impl<T: const Add<Output = T> + const Sub<Output = T> + const Mul<Output = T> + const DivOr + Copy> const const impl<
LerpUtil for T T: const Add<Output = T> + const Sub<Output = T> + const Mul<Output = T> + const DivOr + Copy,
> LerpUtil for T
{ {
/// linear interpolation /// linear interpolation
/// from * (1.0 - self) + to * self /// from * (1.0 - self) + to * self
@@ -37,7 +38,7 @@ macro_rules! impl_op {
use super::*; use super::*;
#[allow(unused_imports)] #[allow(unused_imports)]
use std::ops::*; use std::ops::*;
impl const $op for $T { const impl $op for $T {
type Output = Self; type Output = Self;
fn $fn(self, rhs: Self) -> Self::Output { fn $fn(self, rhs: Self) -> Self::Output {
@@ -46,12 +47,12 @@ macro_rules! impl_op {
} }
} }
} }
impl const $opa for $T { const impl $opa for $T {
fn $fna(&mut self, rhs: Self) { fn $fna(&mut self, rhs: Self) {
*self = self.$fn(rhs); *self = self.$fn(rhs);
} }
} }
impl const $op<f32> for $T { const impl $op<f32> for $T {
type Output = Self; type Output = Self;
fn $fn(self, rhs: f32) -> Self::Output { fn $fn(self, rhs: f32) -> Self::Output {
@@ -60,7 +61,7 @@ macro_rules! impl_op {
} }
} }
} }
impl const $op<$T> for f32 { const impl $op<$T> for f32 {
type Output = $T; type Output = $T;
fn $fn(self, rhs: $T) -> Self::Output { fn $fn(self, rhs: $T) -> Self::Output {
@@ -69,7 +70,7 @@ macro_rules! impl_op {
} }
} }
} }
impl const $opa<f32> for $T { const impl $opa<f32> for $T {
fn $fna(&mut self, rhs: f32) { fn $fna(&mut self, rhs: f32) {
*self = self.$fn(rhs); *self = self.$fn(rhs);
} }
+11
View File
@@ -4,6 +4,17 @@ pub struct SlotId {
genr: u32, genr: u32,
} }
impl SlotId {
/// A stable, collision-free `u64` encoding of this id -- for a caller
/// (accesskit's `NodeId`, today) that wants a flat integer key rather
/// than the two `u32`s. `idx` is offset by one so no real id ever
/// encodes to 0, which callers can then reserve for their own
/// out-of-band root/window node.
pub fn as_u64(&self) -> u64 {
((self.idx as u64) + 1) << 32 | self.genr as u64
}
}
pub struct SlotVec<T> { pub struct SlotVec<T> {
data: Vec<(u32, Option<T>)>, data: Vec<(u32, Option<T>)>,
free: Vec<u32>, free: Vec<u32>,
+1 -1
View File
@@ -67,7 +67,7 @@ impl_op!(Vec2 Sub sub; x y);
impl_op!(Vec2 Mul mul; x y); impl_op!(Vec2 Mul mul; x y);
impl_op!(Vec2 Div div; x y); impl_op!(Vec2 Div div; x y);
impl const DivOr for Vec2 { const impl DivOr for Vec2 {
fn div_or(self, rhs: Self, other: Self) -> Self { fn div_or(self, rhs: Self, other: Self) -> Self {
Self { Self {
x: self.x.div_or(rhs.x, other.x), x: self.x.div_or(rhs.x, other.x),
+45 -17
View File
@@ -1,4 +1,4 @@
use crate::{Axis, AxisT, Len, Painter, SizeCtx}; use crate::{Painter, Size};
use std::any::Any; use std::any::Any;
mod data; mod data;
@@ -16,31 +16,59 @@ pub use view::*;
pub use widgets::*; pub use widgets::*;
pub trait Widget: Any { pub trait Widget: Any {
fn draw(&mut self, painter: &mut Painter); /// Draw within `painter.region()` (the space the parent offered) and
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len; /// report how much of it was actually used, per axis.
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len; fn draw(&mut self, painter: &mut Painter) -> Size;
/// True if `draw`'s output (both the primitives it writes and the
/// `Size` it returns) is the same for any `painter.region()` of the
/// same *content* -- an icon, a fixed-size rect, an already-decoded
/// image at its natural size. Default `false` (redraw on any change to
/// the offered region) because assuming independence wrongly produces
/// a stale draw; a widget must opt in. See LAYOUT.md.
fn is_size_independent(&self) -> bool {
false
} }
pub trait WidgetAxisFns { /// What kind of control this is, for the AccessKit tree `ui::access`
fn desired_len<A: AxisT>(&mut self, ctx: &mut SizeCtx) -> Len; /// builds (RUST.md's I4). Only consulted for a widget that also has an
/// explicit `.label()` -- an unnamed widget is never visited by that
/// tree at all, named or not, so the default here costs nothing except
/// at the handful of call sites that opt in. Default `Unknown` (a
/// generic control with no more specific semantics); a widget with a
/// real platform equivalent -- `TextEdit`'s `MultilineTextInput` --
/// overrides it.
fn access_role(&self) -> accesskit::Role {
accesskit::Role::Unknown
} }
impl<W: Widget + ?Sized> WidgetAxisFns for W { /// Advance whatever this widget is animating to `now`, and say whether
fn desired_len<A: AxisT>(&mut self, ctx: &mut SizeCtx) -> Len { /// it is still animating afterwards. Default: nothing is, so a widget
match A::get() { /// opts in by overriding this *and* by something calling
Axis::X => self.desired_width(ctx), /// [`crate::UiData::animate`] with its id when the animation starts --
Axis::Y => self.desired_height(ctx), /// which is that animation's path out, since the driver
} /// ([`crate::UiData::tick_animations`]) drops every id whose `tick`
/// answers `false`.
///
/// Called once per frame, before the frame's draw, by whichever
/// backend owns the surface; a `true` answer is what makes that
/// backend ask for another frame. So this is the only thing in iris
/// that moves without an input event, and a widget that animates
/// without registering simply never moves -- which is exactly how a
/// finger fling looked on Iris's phone before this existed.
#[allow(unused_variables)]
fn tick(&mut self, now: std::time::Instant) -> bool {
false
} }
} }
impl Widget for () { impl Widget for () {
fn draw(&mut self, _: &mut Painter) {} fn draw(&mut self, _: &mut Painter) -> Size {
fn desired_width(&mut self, _: &mut SizeCtx) -> Len { Size::ZERO
Len::ZERO
} }
fn desired_height(&mut self, _: &mut SizeCtx) -> Len {
Len::ZERO fn is_size_independent(&self) -> bool {
true
} }
} }
+20 -2
View File
@@ -11,6 +11,11 @@ pub struct Widgets {
send: Sender<WidgetId>, send: Sender<WidgetId>,
recv: Receiver<WidgetId>, recv: Receiver<WidgetId>,
pub(crate) waiting: HashSet<WidgetId>, pub(crate) waiting: HashSet<WidgetId>,
/// Every widget that has ever been given an explicit `.label()` --
/// `ui::access::AccessTree` walks exactly this set, not the whole
/// arena, so a widget nobody named costs it nothing. Symmetric with
/// `free_next` below, which is this set's one removal path.
named: HashSet<WidgetId>,
} }
impl Widgets { impl Widgets {
@@ -20,6 +25,7 @@ impl Widgets {
needs_redraw: Default::default(), needs_redraw: Default::default(),
vec: Default::default(), vec: Default::default(),
waiting: Default::default(), waiting: Default::default(),
named: Default::default(),
send, send,
recv, recv,
} }
@@ -95,9 +101,20 @@ impl Widgets {
&self.data(id.id()).unwrap().label &self.data(id.id()).unwrap().label
} }
/// useful for debugging /// Also the one place a widget opts into `ui::access`'s AccessKit tree
/// (RUST.md's I4) -- see `named`'s doc comment.
pub fn set_label(&mut self, id: impl IdLike, label: String) { pub fn set_label(&mut self, id: impl IdLike, label: String) {
self.data_mut(id.id()).unwrap().label = label; let id = id.id();
self.data_mut(id).unwrap().label = label;
self.named.insert(id);
}
/// Every widget with an explicit name, for `ui::access::AccessTree` to
/// walk. Order is unspecified; `AccessTree` doesn't need one; a screen
/// reader's own traversal is worked out by uiautomator from each
/// node's on-screen bounds instead.
pub fn named(&self) -> impl Iterator<Item = WidgetId> + '_ {
self.named.iter().copied()
} }
pub fn data_mut(&mut self, id: impl IdLike) -> Option<&mut WidgetData> { pub fn data_mut(&mut self, id: impl IdLike) -> Option<&mut WidgetData> {
@@ -107,6 +124,7 @@ impl Widgets {
pub fn free_next(&mut self) -> Option<WidgetId> { pub fn free_next(&mut self) -> Option<WidgetId> {
let next = self.recv.try_recv().ok()?; let next = self.recv.try_recv().ok()?;
self.vec.free(next); self.vec.free(next);
self.named.remove(&next);
Some(next) Some(next)
} }
+104
View File
@@ -0,0 +1,104 @@
//! (d) of IRIS_TODO.md's "Benchmarks" item: 1,000 image rows, checking that
//! standalone-image bind-group *creation* -- a real `wgpu` resource, unlike
//! the counters in `benches/message_lazy_span.rs` -- goes to zero once every
//! image has loaded. This needs an actual `wgpu` device (`GpuTextures`,
//! `UiRenderNode`), so unlike the rest of the suite it cannot run as a
//! plain binary; run it through `iris/run-headless.sh bench_images`, which
//! gives it a real (headless, GPU-accelerated) compositor and surface. See
//! `run-bench.sh` for the wrapper that greps its output into one line.
//!
//! Each `RedrawRequested` prints the frame number and
//! `UiRenderNode::take_image_bind_group_creates()` for that frame, then
//! requests another redraw (nothing else marks the scene dirty, so without
//! this the app would only ever draw once). The first frame is expected to
//! report 1,000 (one create per image, on first load); the steady state
//! IRIS_TODO.md asks this scenario to prove is every frame after settling
//! down to 0.
//!
//! After `SETTLE_FRAMES` it appends one *new* image row (a transcript
//! receiving one more message) and keeps counting -- a chat transcript's
//! real access pattern is "one more image arrives," not "reload the whole
//! list," so the steady-state question that actually matters is the
//! *incremental* cost of that one append, not just whether an untouched
//! scene costs zero. It exits after `FRAMES`.
use iris::prelude::*;
const ROWS: usize = 1000;
const SETTLE_FRAMES: usize = 4;
const FRAMES: usize = 6;
#[derive(DefaultUiState)]
struct State {
ui_state: DefaultUiState,
span: WeakWidget<Span>,
frame: usize,
appended: bool,
}
impl DefaultAppState for State {
fn new(
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
let mut span = Span::empty(Dir::DOWN);
for _ in 0..ROWS {
let img = image::DynamicImage::new_rgba8(32, 32);
let widget = image::<DefaultRsc<Self>>(img)(rsc);
let widget = rsc.ui.widgets.add_strong(widget);
span.push(widget.any());
}
let span = rsc.ui.widgets.add_strong(span);
let span_weak = span.weak();
let root = rsc
.ui
.widgets
.add_strong(ScrollArea::new(span.any(), Axis::Y, Pin::End));
ui_state.set_root(root.any());
Self {
ui_state,
span: span_weak,
frame: 0,
appended: false,
}
}
fn window_event(
&mut self,
event: winit::event::WindowEvent,
rsc: &mut DefaultRsc<Self>,
_render: &mut UiRenderState,
) {
if !matches!(event, winit::event::WindowEvent::RedrawRequested) {
return;
}
self.frame += 1;
let creates = self.ui_state.renderer.ui.take_image_bind_group_creates();
println!(
"BENCH_IMAGES frame={} bind_group_creates={creates}",
self.frame
);
if self.frame == SETTLE_FRAMES && !self.appended {
self.appended = true;
let img = image::DynamicImage::new_rgba8(32, 32);
let widget = image::<DefaultRsc<Self>>(img)(rsc);
let widget = rsc.ui.widgets.add_strong(widget);
rsc.ui
.widgets
.get_mut(&self.span)
.unwrap()
.push(widget.any());
println!("BENCH_IMAGES appended one image after settling");
}
if self.frame < FRAMES {
self.ui_state.window.request_redraw();
} else {
std::process::exit(0);
}
}
}
fn main() {
DefaultApp::<State>::run();
}
+122
View File
@@ -0,0 +1,122 @@
//! RUST.md's I3: `iris::widget::LazySpan` with 800 rows of varied-length
//! wrapped text, one in twelve carrying a small image, scrollable with the
//! mouse wheel. Run headless with `iris/run-headless.sh message_list --shot
//! /tmp/message_list.png` -- there is no display on this machine, so that
//! is the only way to see it rendered; `run-tests.sh`/`cargo test` never
//! touch this file.
//!
//! Rows alternate two background tints so a screenshot can show the
//! boundary between adjacent rows even where the text itself wraps to a
//! different number of lines -- exactly the "variable-height rows" I3
//! asks for, and the thing a virtualised list gets wrong first if it is
//! wrong at all (a gap, an overlap, a row the wrong colour). This example
//! is also what found `LazySpan::place`'s oversized-background bug (see
//! lazy_span.rs's module doc and its `a_fill_shaped_background_is_not_left_
//! oversized` test) -- a plain unit test could have (and now does) catch
//! it directly, but it was this screenshot rendering as a single blank
//! tinted rectangle that pointed at it first.
use iris::prelude::*;
use winit::{dpi::LogicalSize, window::WindowAttributes};
fn main() {
DefaultApp::<State>::run();
}
#[derive(DefaultUiState)]
struct State {
ui_state: DefaultUiState,
}
const ROWS: usize = 800;
const IMAGE_EVERY: usize = 12;
/// Repeats a short sentence a varying number of times per row so real
/// wrapping happens at every row height from one line to several, rather
/// than every row being identically tall (which would render correctly
/// even with a broken height measurement).
fn row_text(i: usize) -> String {
const SENTENCE: &str =
"Iris lays out this row once and moves it on scroll, never re-laying it out. ";
let repeats = 1 + (i * 7) % 5;
format!("Message {i}: {}", SENTENCE.repeat(repeats))
}
/// A small solid-colour square standing in for a real decoded image --
/// what matters for I3 is that a row can carry an `Image` widget at all,
/// not what the picture shows.
fn row_image(i: usize) -> image::DynamicImage {
let hue = ((i * 47) % 255) as u8;
image::RgbaImage::from_pixel(48, 48, image::Rgba([hue, 128, 255 - hue, 255])).into()
}
fn build_row<Rsc: UiRsc + 'static>(rsc: &mut Rsc, i: usize) -> StrongWidget {
let tint = if i.is_multiple_of(2) {
Color::rgb(120, 130, 170)
} else {
Color::rgb(70, 80, 140)
};
let text_color = Color::BLACK;
if i.is_multiple_of(IMAGE_EVERY) {
let text = wtext(row_text(i))
.wrap(true)
.color(text_color)
.add_strong(rsc)
.any();
let img = image::<Rsc>(row_image(i))(rsc);
let img = rsc.widgets_mut().add_strong(img).any();
let mut span = Span::empty(Dir::DOWN);
span.push(text);
span.push(img);
span.pad(dp(8.0))
.background(rect(tint))
.add_strong(rsc)
.any()
} else {
wtext(row_text(i))
.wrap(true)
.color(text_color)
.pad(dp(8.0))
.background(rect(tint))
.add_strong(rsc)
.any()
}
}
impl DefaultAppState for State {
// A phone-plausible portrait shape (the transcript screen this is
// standing in for). The tiling headless compositor `run-headless.sh`
// uses ignores this and fills its own 1920x1200 output regardless, but
// it's a correct hint for any other backend (a real window manager, or
// android-view) and costs nothing to state.
fn window_attributes() -> WindowAttributes {
WindowAttributes::default().with_inner_size(LogicalSize::new(420.0, 900.0))
}
fn new(
mut ui_state: DefaultUiState,
rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>,
) -> Self {
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
for i in 0..ROWS {
let row = build_row(rsc, i);
list.push_back(LazyItem::new(i as u64, row));
}
// `.scrollable()`, like anything else that scrolls -- here the
// span's own inherent one, which registers the wheel and the drag
// against the controller it already owns rather than wrapping it
// in a `ScrollArea`. Masked outside it, since a `LazySpan` draws
// the row straddling each edge in full and asserts something clips
// it.
let root = list
.scrollable()
.masked()
.background(rect(Color::WHITE))
.add_strong(rsc);
ui_state.set_root(root.any());
Self { ui_state }
}
}
+9 -187
View File
@@ -1,14 +1,14 @@
use cosmic_text::Family;
use std::{cell::RefCell, rc::Rc};
use winit::event::WindowEvent;
use iris::prelude::*; use iris::prelude::*;
type ClientRsc = DefaultRsc<Client>; use winit::event::WindowEvent;
fn main() { fn main() {
DefaultApp::<Client>::run(); DefaultApp::<Client>::run();
} }
/// The tabs example: five demo panes plus a message composer, built by
/// `tabs_ui::build` and driven here through the winit backend. The same
/// widget tree also runs on the android-view backend, through
/// `iris-android-app` -- see RUST.md's I2.
#[derive(DefaultUiState)] #[derive(DefaultUiState)]
pub struct Client { pub struct Client {
ui_state: DefaultUiState, ui_state: DefaultUiState,
@@ -21,189 +21,11 @@ impl DefaultAppState for Client {
rsc: &mut DefaultRsc<Self>, rsc: &mut DefaultRsc<Self>,
_: Proxy<Self::Event>, _: Proxy<Self::Event>,
) -> Self { ) -> Self {
let rrect = rect(Color::WHITE).radius(20); let widgets = tabs_ui::build(rsc, &mut ui_state);
let pad_test = ( Self {
rrect.color(Color::BLUE), ui_state,
( info: widgets.info,
rrect
.color(Color::RED)
.sized((100, 100))
.center()
.width(rest(2)),
(
rrect.color(Color::ORANGE),
rrect.color(Color::LIME).pad(10.0),
)
.span(Dir::RIGHT)
.width(rest(2)),
rrect.color(Color::YELLOW),
)
.span(Dir::RIGHT)
.pad(10)
.width(rest(3)),
)
.span(Dir::RIGHT)
.add(rsc);
let span_test = (
rrect.color(Color::GREEN).width(100),
rrect.color(Color::ORANGE),
rrect.color(Color::CYAN),
rrect.color(Color::BLUE).width(rel(0.5)),
rrect.color(Color::MAGENTA).width(100),
rrect.color(Color::RED).width(100),
)
.span(Dir::LEFT)
.add(rsc);
let span_add = Span::empty(Dir::RIGHT).add(rsc);
let add_button = rect(Color::LIME)
.radius(30)
.on(CursorSense::click(), move |_, rsc| {
let child = image(include_bytes!("assets/sungals.png"))
.center()
.add_strong(rsc);
span_add(rsc).push(child);
})
.sized((150, 150))
.align(Align::BOT_RIGHT);
let del_button = rect(Color::RED)
.radius(30)
.on(CursorSense::click(), move |_, rsc| {
span_add(rsc).pop();
})
.sized((150, 150))
.align(Align::BOT_LEFT);
let span_add_test = (span_add, add_button, del_button).stack().add(rsc);
let btext = |content| wtext(content).size(30);
let text_test = (
btext("this is a").align(Align::LEFT),
btext("teeeeeeeest").align(Align::RIGHT),
btext("okkk\nokkkkkk!").align(Align::LEFT),
btext("hmm"),
btext("a"),
(
btext("'").family(Family::Monospace).align(Align::TOP),
btext("'").family(Family::Monospace),
btext(":gamer mode").family(Family::Monospace),
rect(Color::CYAN).sized((10, 10)).center(),
rect(Color::RED).sized((100, 100)).center(),
rect(Color::PURPLE).sized((50, 50)).align(Align::TOP),
)
.span(Dir::RIGHT)
.center(),
wtext("pretty cool right?").size(50),
)
.span(Dir::DOWN)
.add(rsc);
let texts = Span::empty(Dir::DOWN).gap(10).add(rsc);
let msg_area = texts.scrollable().masked().background(rect(Color::SKY));
let add_text = wtext("add")
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
.size(30)
.attr::<Selectable>(())
.on(Submit, move |ctx, rsc| {
let w = ctx.widget;
let content = w.edit(rsc).take();
let text = wtext(content)
.editable(EditMode::MultiLine)
.size(30)
.text_align(Align::LEFT)
.wrap(true)
.attr::<Selectable>(());
let msg_box = text
.background(rect(Color::WHITE.darker(0.5)))
.add_strong(rsc);
texts(rsc).push(msg_box);
})
.add(rsc);
let text_edit_scroll = (
msg_area.height(rest(1)),
(
Rect::new(Color::WHITE.darker(0.9)),
(
add_text.width(rest(1)),
Rect::new(Color::GREEN)
.on(CursorSense::click(), move |ctx, rsc: &mut ClientRsc| {
rsc.run_event::<Submit>(add_text, (), ctx.state);
})
.sized((40, 40)),
)
.span(Dir::RIGHT)
.pad(10),
)
.stack()
.size(StackSize::Child(1))
.layer_offset(1)
.align(Align::BOT),
)
.span(Dir::DOWN)
.add(rsc);
let main = WidgetPtr::new().add(rsc);
let vals = Rc::new(RefCell::new((0, Vec::new())));
let mut switch_button = |color, to: WeakWidget, label| {
let to = to.upgrade(rsc);
let vec = &mut vals.borrow_mut().1;
let i = vec.len();
if vec.is_empty() {
vec.push(None);
main(rsc).set(to);
} else {
vec.push(Some(to));
} }
let vals = vals.clone();
let rect = rect(color)
.on(CursorSense::click(), move |ctx, rsc| {
let (prev, vec) = &mut *vals.borrow_mut();
if let Some(h) = vec[i].take() {
vec[*prev] = main(rsc).replace(h);
*prev = i;
}
ctx.widget(rsc).color = color.darker(0.3);
})
.on(
CursorSense::HoverStart | CursorSense::unclick(),
move |ctx, rsc| {
ctx.widget(rsc).color = color.brighter(0.2);
},
)
.on(CursorSense::HoverEnd, move |ctx, rsc| {
ctx.widget(rsc).color = color;
});
(rect, wtext(label).size(30).text_align(Align::CENTER)).stack()
};
let tabs = (
switch_button(Color::RED, pad_test, "pad"),
switch_button(Color::GREEN, span_test, "span"),
switch_button(Color::BLUE, span_add_test, "image span"),
switch_button(Color::MAGENTA, text_test, "text layout"),
switch_button(
Color::YELLOW.mul_rgb(0.5),
text_edit_scroll,
"text edit scroll",
),
)
.span(Dir::RIGHT);
let info = wtext("").add(rsc);
let info_sect = info.pad(10).align(Align::RIGHT);
((tabs.height(40), main.pad(10)).span(Dir::DOWN), info_sect)
.stack()
.set_root(rsc, &mut ui_state);
Self { ui_state, info }
} }
fn window_event( fn window_event(
+14
View File
@@ -0,0 +1,14 @@
# The compositor `run-headless.sh` starts, because this machine has no
# display. Nothing here is meant to be looked at directly; `grim` is.
#
# No Xwayland: winit talks Wayland natively, and starting an X server is a
# second thing to go wrong for no gain. (`emu`'s config forces it because the
# Android emulator's renderer speaks GLX.)
xwayland disable
# A desktop-shaped output, since this is the desktop half of the port. Larger
# than the window an example opens, so nothing is scaled or clipped.
output HEADLESS-1 mode 1920x1200@60Hz
default_border none
focus_follows_mouse no
+3 -3
View File
@@ -4,9 +4,9 @@ version.workspace = true
edition.workspace = true edition.workspace = true
[dependencies] [dependencies]
proc-macro2 = "1.0.103" proc-macro2 = "1.0.107"
quote = "1.0.42" quote = "1.0.47"
syn = { version = "2.0.111", features = ["full"] } syn = { version = "3.0.5", features = ["full"] }
[lib] [lib]
proc-macro = true proc-macro = true
+15 -3
View File
@@ -18,6 +18,12 @@ struct Input {
} }
struct InputFn { struct InputFn {
/// Everything written above the `fn` -- in practice a `///` doc
/// comment, which is why this exists: `masked_by` and its siblings
/// are public API and rustdoc is where their contract is read, so a
/// macro that silently rejected `///` sent the explanation into an
/// ordinary `//` comment nobody generating docs ever sees.
attrs: Vec<Attribute>,
sig: Signature, sig: Signature,
body: Block, body: Block,
} }
@@ -32,9 +38,10 @@ impl Parse for Input {
input.parse::<Token![;]>()?; input.parse::<Token![;]>()?;
let mut fns = Vec::new(); let mut fns = Vec::new();
while !input.is_empty() { while !input.is_empty() {
let attrs = input.call(Attribute::parse_outer)?;
let sig = input.parse()?; let sig = input.parse()?;
let body = input.parse()?; let body = input.parse()?;
fns.push(InputFn { sig, body }) fns.push(InputFn { attrs, sig, body })
} }
if !input.is_empty() { if !input.is_empty() {
input.error("function expected"); input.error("function expected");
@@ -59,10 +66,15 @@ pub fn widget_trait(input: TokenStream) -> TokenStream {
fns, fns,
} = parse_macro_input!(input as Input); } = parse_macro_input!(input as Input);
let sigs: Vec<_> = fns.iter().map(|f| f.sig.clone()).collect(); // The attributes go on the trait's own signature, which is the one
// rustdoc renders; the impl gets the bare `fn`.
let sigs: Vec<_> = fns
.iter()
.map(|InputFn { attrs, sig, .. }| quote! { #(#attrs)* #sig })
.collect();
let impls: Vec<_> = fns let impls: Vec<_> = fns
.iter() .iter()
.map(|InputFn { sig, body }| quote! { #sig #body }) .map(|InputFn { sig, body, .. }| quote! { #sig #body })
.collect(); .collect();
let Some(GenericParam::Type(state)) = generics.params.first() else { let Some(GenericParam::Type(state)) = generics.params.first() else {
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "rig-input"
version.workspace = true
edition.workspace = true
# Layer 2's input half (docs/RUST.md's "Three test layers"): replays one
# of the `.touch` files the headless tests use into whatever window is
# under a Wayland compositor, so the *same recording* drives the
# assertion layer and the layer a person looks at.
#
# It exists because this machine's compositor has no pointer to move.
# `run-headless.sh` starts sway on the headless backend with no input
# devices at all (`WLR_LIBINPUT_NO_DEVICES=1`, `LIBSEAT_BACKEND=noop`),
# so `swaymsg seat - cursor press` reports success and nothing reaches
# the client -- `swaymsg -t get_seats` shows `capabilities: 0`. wlroots
# 0.19 dropped `WLR_HEADLESS_INPUTS`, and ydotool's uinput device would
# be ignored by a compositor that is not reading libinput. The
# virtual-pointer protocol is what is left, and it is a client protocol,
# so it needs no devices and no root.
# Named for what it does rather than for the crate, since the crate may
# grow a keyboard replay beside it.
[[bin]]
name = "replay-touch"
path = "src/main.rs"
[dependencies]
# `TouchScript` -- the same parser the harness uses, so a file that
# replays here and one that replays headless can never disagree.
iris = { path = ".." }
wayland-client = "0.31.15"
wayland-protocols-wlr = { version = "0.3.12", features = ["client"] }
+164
View File
@@ -0,0 +1,164 @@
//! Replays a `.touch` file into the compositor as a left-button drag --
//! see this crate's `Cargo.toml` for why it exists rather than
//! `swaymsg seat - cursor`.
//!
//! WAYLAND_DISPLAY=… replay-touch WIDTH HEIGHT FILE
//!
//! `WIDTH`/`HEIGHT` are the output's own size, because the virtual
//! pointer protocol positions absolutely against an extent rather than
//! in pixels; passing the output size makes a script's coordinates mean
//! the same pixels they mean in the headless tests.
//!
//! Replayed in real time (the sleeps between samples are the gaps in the
//! file), because winit has no timestamp on a pointer event and dates
//! each one when it arrives -- so a 20ms flick has to actually take
//! 20ms here, unlike layer 1 where the sample carries its own time.
use iris::harness::{TouchAction, TouchScript};
use std::time::Duration;
use wayland_client::protocol::wl_pointer::ButtonState;
use wayland_client::protocol::{wl_registry, wl_seat};
use wayland_client::{Connection, Dispatch, QueueHandle, delegate_noop};
use wayland_protocols_wlr::virtual_pointer::v1::client::{
zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1,
zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1,
};
/// `linux/input-event-codes.h`. The protocol takes the kernel's own
/// button code, not a wayland enum.
const BTN_LEFT: u32 = 0x110;
/// How long the pointer sits at the gesture's first position before the
/// script starts -- see the comment at the pre-step in `main`.
const SETTLE: Duration = Duration::from_millis(200);
#[derive(Default)]
struct Globals {
seat: Option<wl_seat::WlSeat>,
manager: Option<ZwlrVirtualPointerManagerV1>,
}
impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
fn event(
state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
let wl_registry::Event::Global {
name,
interface,
version,
} = event
else {
return;
};
match interface.as_str() {
"wl_seat" => {
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
}
"zwlr_virtual_pointer_manager_v1" => {
state.manager = Some(registry.bind(name, version.min(2), qh, ()));
}
_ => {}
}
}
}
delegate_noop!(Globals: ignore wl_seat::WlSeat);
delegate_noop!(Globals: ZwlrVirtualPointerManagerV1);
delegate_noop!(Globals: ZwlrVirtualPointerV1);
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let [width, height, path] = args.as_slice() else {
eprintln!("usage: replay-touch WIDTH HEIGHT FILE");
std::process::exit(2);
};
let (width, height) = (parse(width, "WIDTH"), parse(height, "HEIGHT"));
let text = std::fs::read_to_string(path)
.unwrap_or_else(|e| fail(&format!("could not read {path}: {e}")));
let script = TouchScript::parse(&text).unwrap_or_else(|e| fail(&e));
let conn = Connection::connect_to_env().unwrap_or_else(|e| {
fail(&format!(
"no wayland display ({e}); is WAYLAND_DISPLAY set?"
))
});
let mut queue = conn.new_event_queue();
let qh = queue.handle();
let display = conn.display();
display.get_registry(&qh, ());
let mut globals = Globals::default();
queue
.roundtrip(&mut globals)
.unwrap_or_else(|e| fail(&format!("wayland roundtrip failed: {e}")));
let manager = globals.manager.as_ref().unwrap_or_else(|| {
fail(
"this compositor does not offer zwlr_virtual_pointer_manager_v1, so a pointer cannot \
be synthesised; sway and every wlroots compositor do",
)
});
let pointer = manager.create_virtual_pointer(globals.seat.as_ref(), &qh, ());
// Put the pointer where the gesture starts and let the compositor
// settle before anything is pressed. Without this the press is
// dropped: sway has just learned about this pointer, and a button
// sent in the same breath as the motion that first puts it over a
// window arrives before there is a focused surface to send it to --
// winit sees `CursorEntered`, the moves and the *release*, never the
// press, so the gesture reads as a hover and nothing scrolls. Found
// by printing winit's own events; the settle is what fixed it.
if let Some(first) = script.samples.first() {
pointer.motion_absolute(0, first.pos.x as u32, first.pos.y as u32, width, height);
pointer.frame();
conn.flush()
.unwrap_or_else(|e| fail(&format!("flush: {e}")));
std::thread::sleep(SETTLE);
}
let mut previous = 0;
for sample in &script.samples {
std::thread::sleep(Duration::from_millis(sample.t_ms - previous));
previous = sample.t_ms;
let t = sample.t_ms as u32;
pointer.motion_absolute(t, sample.pos.x as u32, sample.pos.y as u32, width, height);
// One frame per sample, so the compositor delivers them as
// separate pointer frames rather than coalescing the whole
// gesture -- the shape the file recorded is the point.
pointer.frame();
// The button goes in a frame of its own, *after* the motion has
// been committed. Sent in the same frame as the motion that
// first puts the pointer over the window, sway drops it: the
// client sees `CursorEntered` and the moves but never a
// `MouseInput { state: Pressed }`, so the whole gesture reads as
// a hover and nothing scrolls. Found exactly that way, by
// printing winit's events.
let state = match sample.action {
TouchAction::Down => Some(ButtonState::Pressed),
TouchAction::Up | TouchAction::Cancel => Some(ButtonState::Released),
TouchAction::Move => None,
};
if let Some(state) = state {
pointer.button(t, BTN_LEFT, state);
pointer.frame();
}
conn.flush()
.unwrap_or_else(|e| fail(&format!("flush: {e}")));
}
pointer.destroy();
conn.flush().ok();
}
fn parse(text: &str, what: &str) -> u32 {
text.parse()
.unwrap_or_else(|_| fail(&format!("{what} is not a whole number: {text:?}")))
}
fn fail(message: &str) -> ! {
eprintln!("replay-touch: {message}");
std::process::exit(1);
}
Executable
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
# Runs iris's on-demand benchmark suite (IRIS_TODO.md's "Benchmarks" item).
# Never run by `cargo test`; run this by hand or before/after a layout
# change. Always release -- see AGENTS.md's own rule against reading a
# frame time from a debug build.
#
# ./run-bench.sh # everything
# ./run-bench.sh list # just the CPU-only message-list scenarios
# ./run-bench.sh images # just the GPU bind-group-creation scenario
set -eu
here=$(cd "$(dirname "$0")" && pwd)
cd "$here"
what="${1:-all}"
if [ "$what" = "all" ] || [ "$what" = "list" ]; then
echo "=== message_list (CPU-only, no window) ==="
cargo bench --bench message_list
fi
if [ "$what" = "all" ] || [ "$what" = "images" ]; then
echo "=== bench_images (real wgpu device, via run-headless.sh) ==="
timeout 60 ./run-headless.sh bench_images --seconds 4 2>&1 | grep "^BENCH_IMAGES"
fi
+200
View File
@@ -0,0 +1,200 @@
#!/bin/sh
# Run an iris example on this machine, which has no display.
#
# ./run-headless.sh tabs [-- cargo args]
# ./run-headless.sh tabs --shot /tmp/tabs.png --seconds 4
# ./run-headless.sh phone --phone --dir ../app-rust --shot /tmp/p.png
# ./run-headless.sh phone --phone --dir ../app-rust \
# --replay ../app-rust/touch/flick-120hz.touch --shot /tmp/p.png
#
# `--dir DIR` names the workspace to build in, defaulting to `iris/` (this
# script's own directory). The app's examples -- the phone-sized transcript
# screen and everything else that is about *this product* -- live in
# `app-rust/`, which is a workspace of its own; `replay-touch` is still
# built from iris, since it is part of the rig rather than of either app.
#
# `--phone` is layer 2 of docs/RUST.md's "Three test layers": the output
# and the window take Iris's phone's own size and density (1080x2424 at
# `content_scale` 2.55, from docs/bench/iris-phone-v2-2026-09-06.md,
# carried in `ai_app::ui::fixture::PHONE_*`), and `IRIS_SCALE` hands that
# density to iris the way `DisplayMetrics.density` does on Android
# (`iris::default::content_scale`). So a screenshot from here and one
# from the phone are the same layout at the same density, and what
# differs is only the renderer. Without it the output stays desktop-
# shaped, which is what every other example wants.
#
# `--replay FILE` drives one of the `.touch` recordings the headless
# tests use (`app-rust/touch/`) into the window through
# `rig-input`'s `replay-touch` -- one recording, both layers. With
# `--shot` it also writes `<shot>-before.png` from just before the
# gesture, since "the list moved" is a claim about two pictures.
#
# `--bin` runs a real crate binary instead of an example (E4's
# `ai-app-desktop`, which is a window a person runs, not a demo) --
# `cargo build --bin NAME` instead of `--example NAME`, and
# `target/debug/NAME` instead of `target/debug/examples/NAME`. Its own
# argv (the CLI flags a real binary takes, as opposed to `cargo build`'s
# own flags after `--`) comes through `$RUN_HEADLESS_ARGS`, word-split on
# purpose -- an example never needed one, so there was nowhere to plumb it
# through positionally without disturbing the existing `-- cargo args`
# convention above.
#
# The VM has a real GPU and no display (the `this-machine-graphics` skill
# says what it is and how it fails), so what is missing here is only a
# compositor to give winit a surface. So: a headless sway, the same trick
# `emu` uses for the Android emulator, and `grim` to see the result.
#
# It is deliberately *not* `emu`'s compositor. sway tiles, so adding a window
# to the one an emulator is sitting in resizes that emulator's window, and a
# peer session's `emu up` could join at any moment. This one has its own
# socket and its own runtime directory and goes away with the machine.
set -eu
here=$(cd "$(dirname "$0")" && pwd)
# The workspace `--dir` selects; see the header. `$here` is iris itself.
workdir="$here"
run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless"
seconds=3
shot=""
replay=""
example=""
kind=example
phone=no
# The phone Iris runs the bench on. Not typed from memory: these are
# `ai_app::ui::fixture::PHONE_WIDTH`/`PHONE_HEIGHT`/`PHONE_SCALE`, which
# in turn come from her own reports -- keep the three in step.
PHONE_MODE=1080x2424@120Hz
PHONE_SCALE=2.55
DESKTOP_MODE=1920x1200@60Hz
while [ $# -gt 0 ]; do
case "$1" in
--shot) shot=$2; shift 2 ;;
--seconds) seconds=$2; shift 2 ;;
--bin) kind=bin; shift ;;
--phone) phone=yes; shift ;;
--replay) replay=$2; shift 2 ;;
--dir) workdir=$(cd "$2" && pwd); shift 2 ;;
--) shift; break ;;
*) example=$1; shift ;;
esac
done
[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--phone] [--dir DIR] [--replay TOUCH] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; }
[ -z "$replay" ] || [ -f "$replay" ] || { echo "run-headless: no touch script at $replay" >&2; exit 2; }
mkdir -p "$run"
export SWAYSOCK="$run/sway.sock"
# Named rather than left to sway's pid-based default, so a second run reuses
# this compositor instead of starting another beside it.
if ! swaymsg -t get_version >/dev/null 2>&1; then
rm -f "$SWAYSOCK"
WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 LIBSEAT_BACKEND=noop \
setsid sway -c "$here/headless.conf" >"$run/sway.log" 2>&1 &
i=0
while [ $i -lt 20 ]; do
swaymsg -t get_version >/dev/null 2>&1 && break
i=$((i + 1)); sleep 0.5
done
swaymsg -t get_version >/dev/null 2>&1 || {
echo "run-headless: compositor did not start; see $run/sway.log" >&2
exit 1
}
fi
# Asked of the compositor rather than guessed: sway takes the first free
# wayland-N, and this machine may already have one.
rm -f "$run/display"
swaymsg exec -- "sh -c 'printf %s \"\$WAYLAND_DISPLAY\" > $run/display'" >/dev/null
i=0
while [ $i -lt 20 ]; do
[ -s "$run/display" ] && break
i=$((i + 1)); sleep 0.5
done
[ -s "$run/display" ] || { echo "run-headless: could not read WAYLAND_DISPLAY" >&2; exit 1; }
WAYLAND_DISPLAY=$(cat "$run/display")
export WAYLAND_DISPLAY
echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2
# Set every run rather than only when it changes: this compositor is
# reused across runs (see the socket comment above), so a desktop-shaped
# run after a phone-shaped one would otherwise inherit the phone's output
# and silently screenshot the wrong size.
if [ "$phone" = yes ]; then
mode=$PHONE_MODE
export IRIS_SCALE="$PHONE_SCALE"
echo "run-headless: phone-shaped output $PHONE_MODE at IRIS_SCALE=$PHONE_SCALE" >&2
else
mode=$DESKTOP_MODE
fi
swaymsg output HEADLESS-1 mode "$mode" >/dev/null
# The extent `replay-touch` positions against, so a script's coordinates
# are the output's own pixels.
out_w=${mode%x*}
out_h=${mode#*x}; out_h=${out_h%@*}
# Built before the app starts, so a compile error is not reported as a
# window that failed to move.
[ -z "$replay" ] || (cd "$here" && cargo build --bin replay-touch -p rig-input) >&2
cd "$workdir"
if [ "$kind" = bin ]; then
cargo build --bin "$example" "$@" >&2
bin="$workdir/target/debug/$example"
else
cargo build --example "$example" "$@" >&2
bin="$workdir/target/debug/examples/$example"
fi
# shellcheck disable=SC2086 -- deliberately word-split: this is the
# binary's own argv, not a single path.
"$bin" ${RUN_HEADLESS_ARGS:-} >"$run/$example.log" 2>&1 &
pid=$!
trap 'kill "$pid" 2>/dev/null || true' EXIT INT TERM
# Wait for the window to be mapped rather than for a number of seconds. A
# fixed sleep took an all-black screenshot the first time this ran, when sway
# had started in the same invocation and had not composited its output yet --
# which is indistinguishable from an app that draws nothing.
i=0
while [ $i -lt 40 ]; do
kill -0 "$pid" 2>/dev/null || break
swaymsg -t get_tree --raw 2>/dev/null | grep -q "\"pid\":$pid," && break
i=$((i + 1)); sleep 0.25
done
# Then settle, for whatever the example does after its first frame.
i=0
while [ $i -lt "$((seconds * 2))" ]; do
kill -0 "$pid" 2>/dev/null || break
i=$((i + 1)); sleep 0.5
done
if [ -n "$replay" ] && kill -0 "$pid" 2>/dev/null; then
if [ -n "$shot" ]; then
grim "${shot%.png}-before.png"
echo "run-headless: wrote ${shot%.png}-before.png (before the gesture)" >&2
fi
"$here/target/debug/replay-touch" "$out_w" "$out_h" "$replay"
# A fling outlives the finger: the gesture's own last sample is not
# when the list stops. Long enough for Android's spline to settle
# (`FlingCalculator::duration` tops out around a second and a half).
sleep 2
fi
if kill -0 "$pid" 2>/dev/null; then
[ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
status=0
else
wait "$pid" 2>/dev/null || status=$?
echo "run-headless: $example exited early (status ${status:-0})" >&2
status=${status:-1}
fi
echo "--- $example output ---" >&2
cat "$run/$example.log" >&2
exit "$status"
+11
View File
@@ -0,0 +1,11 @@
# iris needs nightly (see the #![feature] list in core/src/lib.rs and src/lib.rs).
# The pin is dated rather than "nightly" because the const-traits feature set
# changes shape between nightlies: on 2026-09-04 the vendored January tree would
# not parse at all, because `impl const Trait for T` had become
# `const impl Trait for T`. A rolling channel turns that into a build that
# breaks unattended on whatever machine Dev Updater happens to build on.
# Advance this deliberately, with the feature list in RUST.md's I0b.
[toolchain]
channel = "nightly-2026-09-03"
components = ["clippy", "rustfmt"]
targets = ["aarch64-linux-android", "x86_64-linux-android"]
+129
View File
@@ -0,0 +1,129 @@
//! Pass conditions for RUST.md's I4, exercised the same way
//! `layout_tests.rs` exercises LAYOUT.md's: `AccessTree` only touches
//! `Widgets`/`UiRenderState`, neither of which needs a GPU or a window, so
//! it can be driven directly against `layout_tests::TestRsc`.
use crate::layout_tests::TestRsc;
use crate::prelude::*;
#[test]
fn a_named_widget_reaches_the_tree_with_its_role_and_bounds() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let leaf: WeakWidget<Rect> = rect(UiColor::WHITE).label("Add task").add(&mut rsc);
let root = leaf.upgrade(&mut rsc).any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
let mut access = AccessTree::new();
let update = access
.update(rsc.widgets(), &render, &rsc)
.expect("a first draw with a named widget must produce a tree");
// One node for the widget, one for the synthetic window root.
assert_eq!(update.nodes.len(), 2);
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() != accesskit::Role::Window)
.expect("the named widget's own node");
assert_eq!(node.label(), Some("Add task"));
assert_eq!(node.role(), accesskit::Role::Unknown);
let bounds = node.bounds().expect("a drawn widget reports its bounds");
let region = render
.window_region(&leaf, &rsc)
.expect("the widget is active after render.update");
assert_eq!(bounds.x0, region.top_left.x as f64);
assert_eq!(bounds.y0, region.top_left.y as f64);
assert_eq!(bounds.x1, region.bot_right.x as f64);
assert_eq!(bounds.y1, region.bot_right.y as f64);
}
#[test]
fn a_widget_with_no_label_never_reaches_the_tree() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let root = rsc.ui.widgets.add_strong(rect(UiColor::WHITE));
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root.any(), &mut rsc);
let mut access = AccessTree::new();
assert!(
access.update(rsc.widgets(), &render, &rsc).is_none(),
"no widget was ever `.label()`ed, so there is nothing to report -- \
not even an empty tree change"
);
}
/// LAYOUT.md's "a moved subtree" lesson applies here too: `resolved_region`
/// (which `window_region` sits on) walks the move-offset chain, so a
/// widget moved via `Offset` -- not redrawn from scratch -- must still
/// report where it actually ended up.
#[test]
fn bounds_follow_a_moved_widget_and_updates_stay_incremental() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let leaf: WeakWidget<Rect> = rect(UiColor::WHITE).label("thing").add(&mut rsc);
let leaf_strong = leaf.upgrade(&mut rsc).any();
let offset = rsc.ui.widgets.add_strong(Offset {
inner: leaf_strong,
amt: UiVec2::ZERO,
});
let offset_id = offset.weak();
let root = offset.any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
let mut access = AccessTree::new();
access
.update(rsc.widgets(), &render, &rsc)
.expect("the first draw is always a change");
assert_eq!(access.take_rebuilds(), 1);
// Unchanged frame: nothing moved, nothing renamed -- `update` must
// report no change, and the rebuild counter (I4's twin of
// `take_counters`) must stay at 0.
render.update(&root, &mut rsc);
assert!(access.update(rsc.widgets(), &render, &rsc).is_none());
assert_eq!(access.take_rebuilds(), 0);
// Move the child via `Offset` (a move-offset write, not necessarily a
// full redraw of the leaf -- see `resolve_move_chain`) and confirm the
// reported bounds shifted by exactly that amount, in exactly one more
// rebuild.
let before = render
.window_region(&leaf, &rsc)
.expect("active before the move");
rsc.ui.widgets.get_mut(&offset_id).unwrap().amt = UiVec2::abs(Vec2::new(50.0, 0.0));
render.update(&root, &mut rsc);
let update = access
.update(rsc.widgets(), &render, &rsc)
.expect("a moved named widget is a change");
assert_eq!(access.take_rebuilds(), 1);
let after = render
.window_region(&leaf, &rsc)
.expect("still active after the move");
// Not asserting the exact delta: `Offset`'s own `amt` -> pixel mapping
// is that widget's business, not this tree's. What I4 owns is that
// `AccessTree` reports whatever `window_region` says *now* -- so the
// node must have moved, and in the direction the offset moved it.
assert!(
after.top_left.x > before.top_left.x,
"the leaf's reported bounds must move right along with its offset"
);
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() != accesskit::Role::Window)
.unwrap();
let bounds = node.bounds().unwrap();
assert_eq!(bounds.x0, after.top_left.x as f64);
}
+86
View File
@@ -0,0 +1,86 @@
//! I4 (RUST.md): the Android half of the AccessKit push, over
//! `accesskit_android::Adapter` and android-view's
//! `AccessibilityNodeProvider`. Carries E1's mitigation for the adapter's
//! reproducible abort: `accesskit_android`'s `State` (0.4.0 and 0.8.0
//! alike) never moves back to `Inactive` once a client attaches, so once
//! one has, every later `QueuedEvents::raise` reaches
//! `AccessibilityManager.sendAccessibilityEvent` -- which throws if
//! accessibility has since been switched off (or the client detached),
//! and android-view's `panic = "abort"` turns that Java exception into a
//! process kill. `raise_if_enabled` is the gate: ask
//! `AccessibilityManager.isEnabled()` immediately before every `raise`
//! and drop the events instead of calling it when the answer is no. See
//! RUST.md's E1 box for the full repro.
use accesskit::{ActionHandler, ActionRequest, ActivationHandler, TreeUpdate};
use accesskit_android::QueuedEvents;
use android_view::{
View,
jni::{JNIEnv, objects::JObject},
};
use iris_core::{AccessTree, UiRenderState, UiRsc, Widgets};
/// The `ActivationHandler` `accesskit_android::Adapter` asks for its
/// initial tree from -- unlike `accesskit_winit`'s handlers (see
/// `default/access.rs`), this one is only ever invoked synchronously from
/// inside a JNI callback that already holds everything it needs, so it can
/// just borrow `IrisViewPeer`'s own fields for the length of one call
/// rather than going through a channel.
pub(super) struct AndroidAccessSource<'a> {
pub widgets: &'a Widgets,
pub render: &'a UiRenderState,
pub rsc: &'a dyn UiRsc,
}
impl ActivationHandler for AndroidAccessSource<'_> {
fn request_initial_tree(&mut self) -> Option<TreeUpdate> {
Some(AccessTree::build_full(self.widgets, self.render, self.rsc))
}
}
/// Every AccessKit action request is inert here -- see this module's doc
/// comment and `default/access.rs`'s matching handler for why: a screen
/// reader's tap on a named node is a real touch delivered at that node's
/// bounds, which the ordinary pointer path already handles once the
/// bounds `AccessTree` reports are right.
pub(super) struct NullActionHandler;
impl ActionHandler for NullActionHandler {
fn do_action(&mut self, _request: ActionRequest) {}
}
fn is_accessibility_enabled<'local>(env: &mut JNIEnv<'local>, view: &View<'local>) -> bool {
let context = view.context(env);
let name = env.new_string("accessibility").unwrap();
let manager: JObject = env
.call_method(
&context.0,
"getSystemService",
"(Ljava/lang/String;)Ljava/lang/Object;",
&[(&name).into()],
)
.unwrap()
.l()
.unwrap();
if manager.is_null() {
return false;
}
env.call_method(&manager, "isEnabled", "()Z", &[])
.unwrap()
.z()
.unwrap()
}
/// The one place `QueuedEvents::raise` may be called -- see this module's
/// doc comment. Every call site pushes this as a deferred callback rather
/// than calling it inline, matching android-view's own demo: `raise`
/// itself asks not to be called while the caller holds locks a framework
/// callback might, and a deferred callback runs after the current one has
/// returned them.
pub(super) fn raise_if_enabled<'local>(
env: &mut JNIEnv<'local>,
view: &View<'local>,
events: QueuedEvents,
) {
if is_accessibility_enabled(env, view) {
events.raise(env, &view.0);
}
}
+29
View File
@@ -0,0 +1,29 @@
use crate::attr::{FocusHost, recent_click};
use crate::prelude::*;
use super::view::HasAndroidUiState;
impl<T: HasAndroidUiState> FocusHost for T {
fn recent_click(&mut self) -> bool {
recent_click(&mut self.android_state_mut().last_click)
}
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
self.android_state_mut().focus = id;
}
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
self.android_state().focus == Some(id)
}
fn focus_gained(&mut self, region: Option<PixelRegion>) {
// Showing the keyboard is a JNI call (`InputMethodManager.showSoftInput`),
// and this runs deep inside the platform-agnostic sensor dispatch
// with no `CallbackCtx` in reach -- `IrisViewPeer::after_input`
// (`view.rs`) is what actually makes the call, right after the
// sensor pass that got here returns.
if region.is_some() {
self.android_state_mut().pending_show_keyboard = true;
}
}
}
+318
View File
@@ -0,0 +1,318 @@
//! `InputConnection`, implemented directly against a focused `TextEdit`
//! rather than against a stand-in editor the way android-view's own demo
//! does over its `parley::PlainEditor` -- I1 already put parley behind
//! `TextEdit`, so this is that same bridge, just wired to iris's widget
//! instead of a bespoke one. Follows `demo/src/lib.rs`'s
//! `impl InputConnection for DemoViewPeer`, which is where RUST.md's E1
//! found the shape this needs (`text_before_cursor` is what gets Gboard's
//! suggestion strip to read real words out of the buffer).
//!
//! Two things the demo tracks that this does not, both noted rather than
//! silently dropped: a real "composing region" distinct from the
//! selection (`set_composing_region` here just moves the caret, since
//! `TextEdit` has no third range to hold one), and batch-edit coalescing
//! (`begin`/`end_batch_edit` are no-ops -- a redraw mid-batch costs a frame
//! it does not need to, not correctness).
use crate::prelude::*;
use android_view::{
CAP_MODE_SENTENCES, CallbackCtx, EditorInfo, IME_FLAG_NO_ENTER_ACTION, IME_FLAG_NO_EXTRACT_UI,
IME_FLAG_NO_FULLSCREEN, INPUT_TYPE_CLASS_TEXT, INPUT_TYPE_TEXT_FLAG_AUTO_CORRECT,
INPUT_TYPE_TEXT_FLAG_CAP_SENTENCES, INPUT_TYPE_TEXT_FLAG_MULTI_LINE, InputConnection,
caps_mode,
};
use std::borrow::Cow;
use super::view::{AndroidAppState, IrisViewPeer};
/// Byte offset -> UTF-16 code unit offset, the unit every `InputConnection`
/// method speaks in (Java strings are UTF-16). `TextEdit` is byte-indexed
/// throughout since I1 moved it to parley -- see `edit.rs`'s doc comment on
/// `text()` -- so every crossing of this boundary goes through here rather
/// than through ad hoc counting at each call site.
fn byte_to_utf16(text: &str, byte_idx: usize) -> usize {
text[..byte_idx].encode_utf16().count()
}
fn utf16_to_byte(text: &str, utf16_idx: usize) -> usize {
let mut utf16_len = 0;
for (byte_idx, ch) in text.char_indices() {
if utf16_len >= utf16_idx {
return byte_idx;
}
utf16_len += ch.len_utf16();
}
text.len()
}
impl<State: AndroidAppState> IrisViewPeer<State> {
fn focus(&self) -> Option<WeakWidget<TextEdit>> {
self.state.android_state().focus
}
/// Tell Gboard where the caret/selection and the composing region
/// actually are, via `InputMethodManager.updateSelection` -- every one
/// of android-view's own demo's `set_composing_text_internal`/`render`
/// calls this, and this bridge never did, which is what left Gboard's
/// own model of the field diverging from `TextEdit`'s real one after
/// the very first edit (RUST.md's P0 box, "doesn't enter it until I
/// hit space, and also doesn't move cursor forward" -- Gboard holds
/// its composing keystrokes back until it believes the app has caught
/// up, and without this call it never does). Called from
/// [`IrisViewPeer::after_input`], the one tail every touch/key/IME
/// callback already runs through, rather than duplicated at each of
/// this file's mutating methods.
///
/// `candidates_start`/`candidates_end` report the composing region;
/// `-1, -1` when nothing is composing, matching `EditorInfo`'s own
/// convention. `compose_len` is tracked in `char`s (this module's doc
/// comment), so this reports it as that many UTF-16 units back from the
/// caret -- exact for the common BMP case, the same approximation
/// `set_composing_text` already makes.
pub(super) fn update_ime_selection(&mut self, ctx: &mut CallbackCtx) {
let Some(focus) = self.focus() else { return };
let text = &self.rsc[focus];
let Some(sel) = text.selection_range() else {
return;
};
let content = text.text();
let sel_start = byte_to_utf16(content, sel.start) as i32;
let sel_end = byte_to_utf16(content, sel.end) as i32;
let compose_len = self.state.android_state().compose_len;
let (comp_start, comp_end) = if compose_len > 0 {
let caret = byte_to_utf16(content, text.caret().unwrap_or(sel.end)) as i32;
(caret - compose_len as i32, caret)
} else {
(-1, -1)
};
let imm = ctx.view.input_method_manager(&mut ctx.env);
imm.update_selection(
&mut ctx.env,
&ctx.view,
sel_start,
sel_end,
comp_start,
comp_end,
);
}
}
impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
fn on_create_input_connection<'local>(
&mut self,
ctx: &mut CallbackCtx<'local>,
out_attrs: &EditorInfo<'local>,
) {
// Set once per `InputConnection`, not per field -- Android calls
// this when the view (not a particular widget) attaches to an
// IME. `MULTI_LINE`/`AUTO_CORRECT`/`CAP_SENTENCES` cover both the
// tabs example's composer and a plain single-line field well
// enough that no per-field variant is worth the extra state yet.
out_attrs.set_input_type(
&mut ctx.env,
INPUT_TYPE_CLASS_TEXT
| INPUT_TYPE_TEXT_FLAG_CAP_SENTENCES
| INPUT_TYPE_TEXT_FLAG_AUTO_CORRECT
| INPUT_TYPE_TEXT_FLAG_MULTI_LINE,
);
out_attrs.set_ime_options(
&mut ctx.env,
IME_FLAG_NO_FULLSCREEN | IME_FLAG_NO_EXTRACT_UI | IME_FLAG_NO_ENTER_ACTION,
);
if let Some(focus) = self.focus() {
let text = &self.rsc[focus];
let sel = text.selection_range().unwrap_or(0..0);
let start = byte_to_utf16(text.text(), sel.start) as i32;
let end = byte_to_utf16(text.text(), sel.end) as i32;
out_attrs.set_initial_sel_start(&mut ctx.env, start);
out_attrs.set_initial_sel_end(&mut ctx.env, end);
let caps = caps_mode(
&mut ctx.env,
text.text(),
start as usize,
CAP_MODE_SENTENCES,
);
out_attrs.set_initial_caps_mode(&mut ctx.env, caps);
}
}
fn text_before_cursor<'slf>(
&'slf mut self,
_ctx: &mut CallbackCtx,
n: i32,
) -> Option<Cow<'slf, str>> {
if n < 0 {
return None;
}
let focus = self.focus()?;
let text = &self.rsc[focus];
let sel = text.selection_range()?;
let end_16 = byte_to_utf16(text.text(), sel.start);
let start_16 = end_16.saturating_sub(n as usize);
let start = utf16_to_byte(text.text(), start_16);
Some(Cow::Borrowed(&text.text()[start..sel.start]))
}
fn text_after_cursor<'slf>(
&'slf mut self,
_ctx: &mut CallbackCtx,
n: i32,
) -> Option<Cow<'slf, str>> {
if n < 0 {
return None;
}
let focus = self.focus()?;
let text = &self.rsc[focus];
let sel = text.selection_range()?;
let len_16 = byte_to_utf16(text.text(), text.text().len());
let start_16 = byte_to_utf16(text.text(), sel.end);
let end_16 = (start_16 + n as usize).min(len_16);
let end = utf16_to_byte(text.text(), end_16);
Some(Cow::Borrowed(&text.text()[sel.end..end]))
}
fn selected_text<'slf>(&'slf mut self, _ctx: &mut CallbackCtx) -> Option<Cow<'slf, str>> {
let focus = self.focus()?;
Some(Cow::Owned(self.rsc[focus].selected_text()?))
}
fn cursor_caps_mode(&mut self, ctx: &mut CallbackCtx, req_modes: u32) -> u32 {
let Some(focus) = self.focus() else {
return 0;
};
let text = &self.rsc[focus];
let Some(caret) = text.caret() else {
return 0;
};
let off = byte_to_utf16(text.text(), caret);
caps_mode(&mut ctx.env, text.text(), off, req_modes)
}
fn delete_surrounding_text(
&mut self,
ctx: &mut CallbackCtx,
before_length: i32,
after_length: i32,
) -> bool {
let Some(focus) = self.focus() else {
return false;
};
let text = &self.rsc[focus];
let Some(sel) = text.selection_range() else {
return false;
};
let content = text.text();
let start_16 =
byte_to_utf16(content, sel.start).saturating_sub(before_length.max(0) as usize);
let len_16 = byte_to_utf16(content, content.len());
let end_16 = (byte_to_utf16(content, sel.end) + after_length.max(0) as usize).min(len_16);
let start = utf16_to_byte(content, start_16);
let end = utf16_to_byte(content, end_16);
focus.edit(&mut self.rsc).delete_byte_range(start, end);
self.after_input(ctx);
true
}
fn delete_surrounding_text_in_code_points(
&mut self,
ctx: &mut CallbackCtx,
before_length: i32,
after_length: i32,
) -> bool {
// Approximated as UTF-16 units rather than Unicode scalar values --
// the two differ only outside the Basic Multilingual Plane, which
// this widget tree does not exercise today. Worth revisiting if a
// field ever needs to edit emoji or other astral-plane text well.
self.delete_surrounding_text(ctx, before_length, after_length)
}
fn set_composing_text(
&mut self,
ctx: &mut CallbackCtx,
text: &str,
_new_cursor_position: i32,
) -> bool {
let Some(focus) = self.focus() else {
return false;
};
// The IME re-sends its whole composition on every keystroke;
// `compose_len` (chars, not bytes -- `TextEditCtx::replace`'s unit)
// is what lets `replace` remove exactly what it inserted last time.
// The same shape as `default::DefaultApp`'s `Ime::Preedit` handling
// for winit.
let compose_len = self.state.android_state().compose_len;
focus.edit(&mut self.rsc).replace(compose_len, text);
self.state.android_state_mut().compose_len = text.chars().count();
self.after_input(ctx);
true
}
fn set_composing_region(&mut self, _ctx: &mut CallbackCtx, _start: i32, _end: i32) -> bool {
// `TextEdit` has no separate composing range to move -- see this
// module's doc comment. Declining (rather than moving the caret,
// which would surprise a caller expecting only a style change)
// is the safer approximation.
false
}
fn finish_composing_text(&mut self, ctx: &mut CallbackCtx) -> bool {
self.state.android_state_mut().compose_len = 0;
self.after_input(ctx);
true
}
fn set_selection(&mut self, ctx: &mut CallbackCtx, start: i32, end: i32) -> bool {
let Some(focus) = self.focus() else {
return false;
};
let text = &self.rsc[focus];
let content = text.text();
// Collapsed to `end`: `TextEditCtx` has no range-selection setter
// yet (nothing before I2 needed one), so an IME-driven selection
// lands the caret at its focus end rather than spanning both.
let byte = utf16_to_byte(content, end.max(0) as usize);
focus.edit(&mut self.rsc).set_cursor_byte(byte);
let _ = start;
self.after_input(ctx);
true
}
fn perform_editor_action(&mut self, _ctx: &mut CallbackCtx, _editor_action: i32) -> bool {
// `IME_FLAG_NO_ENTER_ACTION` above asks the IME not to offer one;
// nothing here needs handling it yet.
false
}
fn begin_batch_edit(&mut self, _ctx: &mut CallbackCtx) -> bool {
true
}
fn end_batch_edit(&mut self, _ctx: &mut CallbackCtx) -> bool {
true
}
fn send_key_event<'local>(
&mut self,
ctx: &mut CallbackCtx<'local>,
event: &android_view::KeyEvent<'local>,
) -> bool {
let key_code = event.key_code(&mut ctx.env);
let handled = super::input::on_key(
&mut self.rsc,
&mut self.state,
&mut ctx.env,
key_code,
event,
);
if handled {
self.after_input(ctx);
}
handled
}
fn request_cursor_updates(&mut self, _ctx: &mut CallbackCtx, _cursor_update_mode: i32) -> bool {
// No cursor-anchor UI to feed -- see RUST.md's I2 notes on what
// this backend does not do yet.
false
}
}
+39
View File
@@ -0,0 +1,39 @@
use crate::prelude::*;
use android_view::{jni::JNIEnv, ndk::event::Keycode};
use super::view::{AndroidAppState, AndroidRsc};
/// Hardware/synthesized key handling for the field that currently has
/// focus. Most typing on Android goes through the IME's `InputConnection`
/// (`android/ime.rs`) instead -- this only sees what a soft keyboard still
/// sends as a real `KeyEvent` in "not fullscreen" mode (Backspace, Enter,
/// the arrow keys on a physical keyboard) plus whatever `unicode_char`
/// reports for a plain key press. Returns whether anything used the event.
pub(super) fn on_key<'local, State: AndroidAppState>(
rsc: &mut AndroidRsc<State>,
state: &mut State,
env: &mut JNIEnv<'local>,
key_code: Keycode,
event: &android_view::KeyEvent<'local>,
) -> bool {
let Some(focus) = state.android_state().focus else {
return false;
};
let mut text = focus.edit(rsc);
match key_code {
Keycode::Del => text.backspace(false),
Keycode::ForwardDel => text.delete(false),
Keycode::DpadLeft => text.motion(Motion::Left, false),
Keycode::DpadRight => text.motion(Motion::Right, false),
Keycode::DpadUp => text.motion(Motion::Up, false),
Keycode::DpadDown => text.motion(Motion::Down, false),
Keycode::MoveHome => text.motion(Motion::LineStart, false),
Keycode::MoveEnd => text.motion(Motion::LineEnd, false),
Keycode::Enter | Keycode::NumpadEnter => text.newline(),
_ => match event.unicode_char(env) {
Some(c) if !c.is_control() => text.insert(&c.to_string()),
_ => return false,
},
}
true
}
+155
View File
@@ -0,0 +1,155 @@
//! Window insets, fed in from outside `ViewPeer`.
//!
//! android-view's registered native methods (`view.rs` in that crate) cover
//! touch, keys, focus, the surface and the IME -- there is nothing for
//! `View.onApplyWindowInsets`, because android-view's own demo does not
//! need it. The back gesture needed no new plumbing at all: with no
//! `OnBackPressedCallback` registered, Android still delivers it as an
//! ordinary `KEYCODE_BACK` `KeyEvent` through the ordinary key path (see
//! `view.rs`'s `on_key_down`), which is the legacy behaviour every app gets
//! by default and is enough for "the back gesture as an event". Insets have
//! no such stand-in, so this module registers one more native method by
//! hand, on the app's own `View` subclass rather than on android-view's.
//!
//! The peer id android-view hands back from `register_view_peer` is opaque
//! outside that crate (`with_peer` is `pub(crate)` there), so there is no
//! way to reach an existing `IrisViewPeer` from a JNI entry point we define
//! ourselves. Instead of forking android-view to add a hook, `new_peer`
//! (`view.rs`) inserts the *same* id into this module's own map, pointing
//! at a plain `Rc<RefCell<Shared>>` cloned into `AndroidUiState` too --
//! so writing here is reading there, with no dependency in either
//! direction on the other's internals.
use android_view::{
View,
jni::{
JNIEnv, NativeMethod,
descriptors::Desc,
objects::JClass,
sys::{jint, jlong},
},
};
use std::{
cell::RefCell,
collections::HashMap,
ffi::c_void,
rc::Rc,
sync::{Mutex, OnceLock},
};
use send_wrapper::SendWrapper;
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
pub struct Insets {
pub left: i32,
pub top: i32,
pub right: i32,
pub bottom: i32,
/// The keyboard's own inset (`WindowInsets.Type.ime()`), in physical
/// pixels, separate from `bottom` (the system bars): a layout wants to
/// know about the keyboard specifically, since it usually means "make
/// room" rather than "stay clear of a corner".
pub ime_bottom: i32,
/// `WindowInsets.isVisible(ime())` -- whether the keyboard is up, which
/// is **not** the same question as `ime_bottom > 0` and is why the two
/// are carried separately. They disagree for the frames the keyboard
/// spends sliding: visible, with a height still on its way to the full
/// one. Anything asking "make how much room" reads `ime_bottom`;
/// anything asking "is the keyboard up" reads this. See
/// `MainActivity.java`'s comment for the history -- the height used to
/// be sent *as* this boolean, which is what left the composer padded by
/// one pixel on Iris's phone.
pub ime_visible: bool,
}
#[derive(Default)]
pub struct Shared {
pub insets: Insets,
/// How many times Java has called `applyWindowInsetsNative` for this
/// peer, whether or not the numbers changed. Deliberately **not** a
/// field of `Insets`, which is compared for equality each frame to
/// decide whether to re-run `on_insets_changed`; a counter in there
/// would make every dispatch look like a change.
///
/// It exists because "the keyboard does not push anything up" has two
/// completely different causes that look identical on screen -- the
/// listener never fired, or it fired with a zero `ime_bottom` -- and
/// Iris has no logcat on her phone (docs/IRIS_TODO.md). This number is
/// in the `Diagnostics` overlay, so one screenshot separates them.
pub updates: u64,
}
type SharedMap = HashMap<jlong, SendWrapper<Rc<RefCell<Shared>>>>;
fn map() -> &'static Mutex<SharedMap> {
static MAP: OnceLock<Mutex<SharedMap>> = OnceLock::new();
MAP.get_or_init(Default::default)
}
/// Called from `view::new_peer` with the same id android-view's
/// `register_view_peer` returned, so a later `apply_window_insets` call
/// (keyed on that id by Java, which only ever sees the one long) reaches
/// the same `Shared` cell `AndroidUiState` reads from.
pub(super) fn register(id: jlong, shared: Rc<RefCell<Shared>>) {
map().lock().unwrap().insert(id, SendWrapper::new(shared));
}
extern "system" fn unregister_insets<'local>(
_env: JNIEnv<'local>,
_view: View<'local>,
peer: jlong,
) {
map().lock().unwrap().remove(&peer);
}
extern "system" fn apply_window_insets<'local>(
mut env: JNIEnv<'local>,
view: View<'local>,
peer: jlong,
left: jint,
top: jint,
right: jint,
bottom: jint,
ime_bottom: jint,
ime_visible: jint,
) {
if let Some(shared) = map().lock().unwrap().get(&peer) {
let mut shared = shared.borrow_mut();
shared.insets = Insets {
left,
top,
right,
bottom,
ime_bottom,
ime_visible: ime_visible != 0,
};
shared.updates += 1;
}
// Insets can change (the keyboard opening) with no resize and no
// touch, so nothing else here would otherwise ask for a frame.
view.post_frame_callback(&mut env);
}
/// Registers `applyWindowInsetsNative` on the app's own `View` subclass.
/// Called once from `JNI_OnLoad` alongside `android_view::register_view_class`.
pub fn register_native_methods<'local, 'other_local>(
env: &mut JNIEnv<'local>,
class: impl Desc<'local, JClass<'other_local>>,
) {
env.register_native_methods(
class,
&[
NativeMethod {
name: "applyWindowInsetsNative".into(),
sig: "(JIIIIII)V".into(),
fn_ptr: apply_window_insets as *mut c_void,
},
NativeMethod {
name: "unregisterInsetsNative".into(),
sig: "(J)V".into(),
fn_ptr: unregister_insets as *mut c_void,
},
],
)
.unwrap();
}
+45
View File
@@ -0,0 +1,45 @@
//! iris's second windowing backend: `android-view` (a `SurfaceView` plus a
//! JNI `ViewPeer`) instead of winit. See RUST.md's I2 for why this exists
//! as a second backend rather than winit's own (unfinished, and blocked on
//! `android-activity`'s backend-feature requirement) Android support, and
//! for the pass condition this was built against.
//!
//! Structured to mirror `default/` module for module: `view.rs` is that
//! module's `app.rs` + `state.rs` combined (android-view has one harness
//! type, `ViewPeer`, where winit splits `ApplicationHandler` from the
//! per-window state), `render.rs` is `render.rs`, `input.rs` is `input.rs`,
//! `attr.rs` is `attr.rs`. `ime.rs` and `insets.rs` have no winit
//! counterpart: winit cannot drive an IME beyond `Ime::Preedit`/`Commit`
//! (RUST.md's E1) and has no concept of Android's window insets at all.
mod access;
mod attr;
mod ime;
mod input;
mod insets;
mod platform;
mod render;
mod view;
pub use insets::Insets;
pub use render::AndroidRenderer;
pub use view::{
AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState, IrisViewPeer, WindowInsets,
new_peer,
};
/// Registers the extra native methods this backend needs beyond what
/// `android_view::register_view_class` covers (window insets -- see
/// `insets.rs`'s doc comment for why that one could not ride along on an
/// existing android-view callback the way the back gesture does). Call
/// from `JNI_OnLoad` alongside `register_view_class`, on the same `View`
/// subclass.
pub fn register_native_methods<'local, 'other_local>(
env: &mut android_view::jni::JNIEnv<'local>,
class: impl android_view::jni::descriptors::Desc<
'local,
android_view::jni::objects::JClass<'other_local>,
>,
) {
insets::register_native_methods(env, class);
}
+86
View File
@@ -0,0 +1,86 @@
use crate::platform::OpenUrl;
use android_view::{
View,
jni::{JNIEnv, objects::JValue},
};
use super::view::HasAndroidUiState;
/// Android's URL opener. Like `FocusHost::focus_gained`'s keyboard, the
/// real work is a JNI call and this runs deep inside the sensor dispatch
/// with no `CallbackCtx` in reach -- so it raises a flag that
/// `IrisViewPeer::after_input` consumes, exactly as
/// `pending_show_keyboard` does.
///
/// Last request wins: two links cannot be tapped in one frame, and a URL
/// left queued from a frame that somehow never reached `after_input`
/// would open at some unrelated later tap, which is worse than dropping
/// it.
impl<T: HasAndroidUiState> OpenUrl for T {
fn open_url(&mut self, url: &str) {
self.android_state_mut().pending_open_url = Some(url.to_string());
}
}
/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the view's
/// own context.
///
/// `FLAG_ACTIVITY_NEW_TASK` because the context here is the view's, which
/// may be an application context rather than the activity's -- Android
/// throws `AndroidRuntimeException` for a non-activity context without it,
/// and it is harmless when the context *is* an activity's.
///
/// Every failure is logged with the URL and returns; there is nothing to
/// fall back to, and the reader will see that nothing happened.
pub(super) fn open_url<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, url: &str) {
match try_open_url(env, view, url) {
Ok(()) => {}
Err(e) => {
// A pending Java exception makes every later JNI call fail in
// ways nowhere near here, so it is cleared at the boundary.
let _ = env.exception_clear();
log::warn!("could not open {url}: {e}");
}
}
}
fn try_open_url<'local>(
env: &mut JNIEnv<'local>,
view: &View<'local>,
url: &str,
) -> Result<(), android_view::jni::errors::Error> {
let context = env
.call_method(&view.0, "getContext", "()Landroid/content/Context;", &[])?
.l()?;
let jurl = env.new_string(url)?;
let uri = env.call_static_method(
"android/net/Uri",
"parse",
"(Ljava/lang/String;)Landroid/net/Uri;",
&[JValue::Object(jurl.as_ref())],
)?;
let action = env.new_string("android.intent.action.VIEW")?;
let intent = env.new_object(
"android/content/Intent",
"(Ljava/lang/String;Landroid/net/Uri;)V",
&[JValue::Object(action.as_ref()), JValue::Object(&uri.l()?)],
)?;
env.call_method(
&intent,
"addFlags",
"(I)Landroid/content/Intent;",
&[JValue::Int(FLAG_ACTIVITY_NEW_TASK)],
)?;
env.call_method(
&context,
"startActivity",
"(Landroid/content/Intent;)V",
&[JValue::Object(&intent)],
)?;
Ok(())
}
/// `android.content.Intent.FLAG_ACTIVITY_NEW_TASK`. A constant rather than
/// a static-field read: it is part of the platform's stable ABI and
/// reading it costs two more JNI calls that can each fail.
const FLAG_ACTIVITY_NEW_TASK: i32 = 0x1000_0000;
+526
View File
@@ -0,0 +1,526 @@
use crate::task::RequestRedraw;
use android_view::{
View,
jni::{JavaVM, objects::GlobalRef},
ndk::native_window::NativeWindow,
};
use iris_core::{UiData, UiRenderNode, UiRenderState};
use pollster::FutureExt;
use std::time::{Duration, Instant};
use wgpu::{
rwh::{DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WindowHandle},
*,
};
pub const CLEAR_COLOR: Color = Color::BLACK;
/// `NativeWindow` (from the surface android-view hands over in
/// `surfaceChanged`) has a window handle but not a display one -- there is
/// exactly one display on Android and `rwh` has a unit variant for it.
/// Mirrors android-view's own demo (`demo/src/lib.rs`'s
/// `AndroidWindowHandle`).
struct AndroidWindowHandle {
window: NativeWindow,
}
impl HasDisplayHandle for AndroidWindowHandle {
fn display_handle(&self) -> Result<DisplayHandle<'_>, HandleError> {
Ok(DisplayHandle::android())
}
}
impl HasWindowHandle for AndroidWindowHandle {
fn window_handle(&self) -> Result<WindowHandle<'_>, HandleError> {
self.window.window_handle()
}
}
/// The android-view surface, unlike winit's window, does not outlive a
/// backgrounding of the activity: `surfaceDestroyed`/`surfaceCreated` (via
/// `SurfaceHolder.Callback`) recreate it, so this holds everything that
/// depends on that surface rather than being built once at startup --
/// `AndroidUiState` holds it as `Option<AndroidRenderer>`, `None` exactly
/// when there is no surface to draw into.
pub struct AndroidRenderer {
surface: Surface<'static>,
device: Device,
queue: Queue,
config: SurfaceConfiguration,
encoder: CommandEncoder,
pub ui: UiRenderNode,
/// The adapter identity, kept past `new()` for the Diagnostics page --
/// `Adapter` itself is not `Clone`, so the three fields the page shows
/// are copied out once here rather than holding the adapter.
pub adapter_name: String,
pub adapter_backend: Backend,
pub adapter_driver: String,
/// Every uncaptured wgpu error since this renderer was created -- see
/// `iris_core::WgpuErrorLog`'s doc comment. Installed on `device` in
/// `new()`, kept here so the Diagnostics page and the per-frame log in
/// `update()` can both read it without a global.
pub wgpu_errors: iris_core::WgpuErrorLog,
/// Frames drawn on this surface -- what gates the first-10-frames log
/// `update()` writes (RUST.md's P0 box, "the first input frame"
/// investigation): a fresh surface is exactly what Iris's own report
/// says renders correctly at first, so the frames that matter are the
/// first several after each `surface_changed`, not an arbitrary window
/// during a long-running session.
frame_count: u64,
/// Physical pixels per dp -- see `android::view::AndroidUiState::
/// content_scale`'s field comment for what this feeds.
content_scale: f32,
}
/// One frame's worth of the counters `render/mod.rs`'s doc comments on
/// `FrameUpdateStats`/`take_image_bind_group_creates`/
/// `take_atlas_pages_grown` describe -- assembled here because the three
/// live on two different calling conventions (`FrameUpdateStats` from this
/// exact `update()` call; the other two describe the *previous* frame,
/// same as `bench_images`' existing use of them) and a diagnostic reader
/// should not have to know that split.
#[derive(Clone, Copy, Debug, Default)]
pub struct FrameDiagnostics {
pub masks_resized: bool,
pub moves_resized: bool,
/// From the previous frame's `update()` -- see the struct doc.
pub atlas_pages_grown_prev: u64,
pub image_bind_group_creates_prev: u64,
}
impl AndroidRenderer {
/// `Err` holds a full, human-readable report for **every** way this
/// can fail -- no surface, no adapter, no device, or wgpu's own error
/// text (`UiRenderNode::new`'s doc comment) plus the adapter identity
/// and the limits/downlevel flags bind-group-layout validation checks
/// against -- rather than the panic wgpu's default error handler would
/// otherwise raise with no caller able to see it. This is what aborted
/// the P0 bench APK on Iris's phone with only "wgpu error: Validation
/// Error" surviving into the crash report (RUST.md's P0 box, "iris
/// bench crash on the phone, 2026-09-06"): `create_bind_group_layout`
/// validates against *this* adapter's downlevel capabilities and
/// limits, which a desktop GPU and the emulator's software renderers
/// never exercised. The caller (`android::view::IrisViewPeer::
/// surface_changed`) logs this one-line-flattened and shows it on
/// screen instead of aborting the process.
pub fn new(
window: NativeWindow,
width: u32,
height: u32,
content_scale: f32,
) -> Result<Self, String> {
// `force-gles` (RUST.md's I5 "Where iris's frame time goes") pins
// the build to GLES, to isolate whether the backend itself explains
// the frame time gap against Compose. `cfg!` rather than a runtime
// switch: there is no way to hand an env var to an already-launched
// Android process on this machine (see the feature's doc in
// Cargo.toml).
//
// Otherwise: **Vulkan where it has an adapter at all, GLES where it
// has none.** `Backends::PRIMARY` leaves `GL` out, so a device
// offering only a GLES adapter had no adapter at all and this
// function aborted the process -- this checkout's emulator, whose
// Vulkan ICD carries no adapter behind it (`NotFound {
// active_backends: VULKAN, no_adapter_backends: VULKAN,
// supported_backends: VULKAN | GL }`), and the crash loop in
// RUST.md's queue.
//
// The choice is made *before any surface exists*, with an instance
// that never touches the window, because **an Android window can be
// connected to one graphics API only**. One instance carrying both
// backends does not work: `create_surface` builds a raw surface per
// backend, Vulkan's `vkCreateAndroidSurfaceKHR` claims the window
// first, and the GLES surface made from the same window then fails
// `configure` as lost -- measured here as "In Surface::configure /
// Invalid surface" followed by an abort in
// `Surface::get_current_texture_view`, "Surface is not configured
// for presentation".
let mut backends = if cfg!(feature = "force-gles") {
Backends::GL
} else {
Backends::PRIMARY
};
// No display handle: an Android surface is built from the
// `NativeWindow` below, and there is no platform connection to hand
// wgpu here the way there is on Wayland.
let mut instance = Instance::new(InstanceDescriptor {
backends,
..InstanceDescriptor::new_without_display_handle()
});
// A build already pinned to GLES has nowhere to fall back to.
if backends != Backends::GL && instance.enumerate_adapters(backends).block_on().is_empty() {
log::warn!(
"iris renderer: no {backends:?} adapter on this device, falling back to GLES"
);
backends = Backends::GL;
instance = Instance::new(InstanceDescriptor {
backends,
..InstanceDescriptor::new_without_display_handle()
});
}
// SAFETY: the `NativeWindow` outlives the surface built from it --
// android-view drops the old renderer (and this surface with it)
// before handing over a new window, in `surface_changed` below.
let surface = instance
.create_surface(SurfaceTarget::from(AndroidWindowHandle { window }))
.map_err(|error| format!("Could not create the android surface: {error}"))?;
// Every step from here to a live device reports rather than
// panics, for the one reason: on the phone these builds run on
// there is no `adb`, so an abort's message reaches a tombstone
// nobody can read and the launcher simply restarts the app --
// which is what a crash loop with no explanation is. The caller
// (`android::view::IrisViewPeer::surface_changed`) puts this
// string on screen and in the app's own log ring instead.
let adapter = instance
.request_adapter(&RequestAdapterOptions {
power_preference: PowerPreference::default(),
compatible_surface: Some(&surface),
force_fallback_adapter: false,
..Default::default()
})
.block_on()
.map_err(|error| format!("No usable GPU adapter for backends {backends:?}: {error}"))?;
// Same request as the winit backend's `UiRenderer::new` -- no
// binding-array features, see TEXTURES.md's "Recommended shape".
// `iris_core::device_limits()` is shared between the two backends;
// see its own doc for why it is not simply `Limits::default()`.
let (device, queue) = adapter
.request_device(&DeviceDescriptor {
required_limits: iris_core::device_limits(),
..Default::default()
})
.block_on()
.map_err(|error| {
format!(
"The adapter {} ({:?}) refused a device: {error}",
adapter.get_info().name,
adapter.get_info().backend,
)
})?;
// wgpu's default handler for an error raised outside `UiRenderNode::
// new`'s own error scopes (i.e. everything past device creation --
// an ordinary frame's `update`/`draw`) is `panic!`, unconditionally,
// with no caller able to intervene: the same mechanism that aborted
// the P0 bench APK once already, just at a different call site. Log
// and record instead of letting that default stand -- RUST.md's P0
// box, "every wgpu uncaptured error ... it must never panic in
// release".
let wgpu_errors = iris_core::WgpuErrorLog::default();
let wgpu_errors_for_handler = wgpu_errors.clone();
device.on_uncaptured_error(std::sync::Arc::new(move |error| {
log::error!("iris wgpu uncaptured error: {error}");
wgpu_errors_for_handler.record(error);
}));
let info = adapter.get_info();
let adapter_name = info.name.clone();
let adapter_backend = info.backend;
// Either half can be empty -- the emulator's GLES adapter reports
// no `driver` and a long `driver_info`, so joining unconditionally
// left a leading space in every log line it appears in.
let adapter_driver = [info.driver.as_str(), info.driver_info.as_str()]
.into_iter()
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" ");
// Say which adapter won, in the same words `default::render` uses,
// and at startup rather than only on the Diagnostics page: the
// backend alone (logged by `view.rs` when a renderer is built) does
// not separate the cases that matter. In this checkout's emulator
// `Gl` is the host's real GPU through virgl, and `Gl` under
// `EMU_GPU=software` is SwiftShader on the CPU; on a phone `Vulkan`
// is the device's own driver. A frame time or a screenshot with no
// record of which of those produced it cannot be read, and the
// fallback above is silent by design.
log::info!(
"iris renderer: {adapter_name} ({adapter_backend:?}, {adapter_driver}) on \
{backends:?}"
);
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
.iter()
.copied()
.find(|f| f.is_srgb())
.unwrap_or(surface_caps.formats[0]);
let config = SurfaceConfiguration {
usage: TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
// wgpu 30's new field; `Auto` is what every earlier version did.
color_space: SurfaceColorSpace::Auto,
width,
height,
present_mode: PresentMode::AutoVsync,
alpha_mode: surface_caps.alpha_modes[0],
desired_maximum_frame_latency: 2,
view_formats: vec![],
};
surface.configure(&device, &config);
let encoder = Self::create_encoder(&device);
// Physical pixels, matching the swapchain's own `width`/`height`
// exactly -- see `android::view::AndroidUiState::content_scale`'s
// field comment for why this is no longer divided into a separate
// logical space (that stopgap is what made text blurry, RUST.md's
// P0 box). `Len::dp` folds the density in at layout time instead,
// so nothing here needs to know it at all.
let window_size = iris_core::util::Vec2::new(width as f32, height as f32);
let ui = match UiRenderNode::new(&device, &queue, &config, window_size) {
Ok(ui) => ui,
Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)),
};
Ok(Self {
surface,
device,
queue,
config,
encoder,
ui,
adapter_name,
adapter_backend,
adapter_driver,
wgpu_errors,
frame_count: 0,
content_scale,
})
}
/// The adapter identity plus every limit and downlevel flag
/// `create_bind_group_layout` validates a storage buffer or texture
/// binding against, followed by wgpu's own error text -- everything a
/// person reading this off a screenshot needs to tell "this adapter
/// lacks X" from "this is a bug in the layout." Named explicitly rather
/// than `{limits:?}`/`{flags:?}` wholesale, because `Limits` alone is
/// dozens of fields nobody asked for -- these are exactly the ones
/// `UiRenderNode::new`'s layouts (`rsc_layout`, `masks_layout`,
/// `primitive_layout`) can fail against, per `CreateBindGroupLayoutError`
/// (`wgpu-core::binding_model`) and its downlevel-flag checks
/// (`wgpu-core::device::resource`, `VERTEX_STORAGE` in particular --
/// the one storage buffer here, `move_offsets`, that is visible to the
/// vertex stage).
fn diagnostic(adapter: &Adapter, wgpu_error: &str) -> String {
let info = adapter.get_info();
let limits = adapter.limits();
let downlevel = adapter.get_downlevel_capabilities();
format!(
"iris could not start rendering. Copy this text and send it to Iris.\n\n\
adapter: {name} ({backend:?}), driver: {driver} {driver_info}\n\
limits: max_storage_buffers_per_shader_stage={max_storage_buffers} \
max_sampled_textures_per_shader_stage={max_sampled_textures} \
max_bind_groups={max_bind_groups} \
max_bindings_per_bind_group={max_bindings} \
max_storage_buffer_binding_size={max_storage_binding} \
min_storage_buffer_offset_alignment={min_storage_align}\n\
downlevel flags: {flags:?}\n\n\
{wgpu_error}",
name = info.name,
backend = info.backend,
driver = info.driver,
driver_info = info.driver_info,
max_storage_buffers = limits.max_storage_buffers_per_shader_stage,
max_sampled_textures = limits.max_sampled_textures_per_shader_stage,
max_bind_groups = limits.max_bind_groups,
max_bindings = limits.max_bindings_per_bind_group,
max_storage_binding = limits.max_storage_buffer_binding_size,
min_storage_align = limits.min_storage_buffer_offset_alignment,
flags = downlevel.flags,
)
}
/// The Diagnostics page's whole report: adapter identity, font
/// resolution, the atlas's own view count, every uncaptured wgpu error
/// so far, and the frame report -- RUST.md's P0 box, "a named
/// `Diagnostics` control ... adapter info, limits, fonts found, atlas
/// format/pages, wgpu errors so far, frame report". One string rather
/// than a struct the caller formats, since the only consumer is a
/// plain `TextView` with a "copy this and send it to Iris" affordance,
/// the same shape `surface_changed`'s crash report already uses
/// (UI_RULES.md: a failure -- or here, a state worth reporting --
/// carries enough to act on where it's shown).
pub fn diagnostics_report(
&self,
font: &iris_core::FontDiagnostics,
frame_report: &str,
) -> String {
let errors = self.wgpu_errors.snapshot();
let errors_text = if errors.is_empty() {
"none".to_string()
} else {
errors.join("\n ")
};
format!(
"iris diagnostics. Copy this text and send it to Iris.\n\n\
adapter: {name} ({backend:?}), driver: {driver}\n\
content_scale: {content_scale}\n\
atlas format: Rgba8Unorm, views live: {views}\n\
fonts: {families_found} families found, default={default_family:?} \
mono={default_mono_family:?}\n\
fonts resolved: regular={regular:?} bold={bold:?} italic={italic:?} \
mono={mono:?}\n\
icon font: {icons:?}\n\
wgpu errors since surface creation:\n {errors_text}\n\n\
{frame_report}",
name = self.adapter_name,
backend = self.adapter_backend,
driver = self.adapter_driver,
content_scale = self.content_scale,
views = self.ui.view_count(),
families_found = font.families_found,
default_family = font.default_family,
default_mono_family = font.default_mono_family,
regular = font.regular_resolved,
bold = font.bold_resolved,
italic = font.italic_resolved,
mono = font.mono_resolved,
icons = font.icon_family,
)
}
fn create_encoder(device: &Device) -> CommandEncoder {
device.create_command_encoder(&CommandEncoderDescriptor {
label: Some("Render Encoder"),
})
}
/// Returns what changed this frame -- see `FrameDiagnostics`'s doc
/// comment for why two of its four fields describe the *previous*
/// frame rather than this one. `IrisViewPeer::render` logs this for
/// the first `DIAGNOSTIC_FRAMES` frames after each `surface_changed`,
/// per RUST.md's P0 box ("the first input frame" investigation): the
/// glyph-wipe Iris reported happens on the first tap or scroll after a
/// fresh surface, so that is exactly the window a report needs to
/// cover, not an arbitrary slice of a long session.
pub fn update(&mut self, ui: &mut UiData, render: &mut UiRenderState) -> FrameDiagnostics {
let atlas_pages_grown_prev = self.ui.take_atlas_pages_grown();
let image_bind_group_creates_prev = self.ui.take_image_bind_group_creates();
let stats = self.ui.update(&self.device, &self.queue, ui, render);
self.frame_count += 1;
FrameDiagnostics {
masks_resized: stats.masks_resized,
moves_resized: stats.moves_resized,
atlas_pages_grown_prev,
image_bind_group_creates_prev,
}
}
/// Frames drawn on this surface so far -- see `frame_count`'s field
/// comment.
pub fn frame_count(&self) -> u64 {
self.frame_count
}
/// Draws and presents one frame, returning the time spent in
/// `queue.submit` plus `present()` -- wherever a driver/GPU/compositor
/// wait would actually show up. The caller (`android::view::render`)
/// already times the whole frame from its own `redraw_to_submit` start;
/// subtracting this from that total is `redraw_to_submit` itself
/// (layout, text, primitive building, and this method's own render-pass
/// recording). RUST.md's I5 "Where iris's frame time goes" diagnosis,
/// added 2026-09-05 -- see `iris_core::FrameReport::record_split`'s own
/// doc for the caveat this shares: `present()` is not fenced against
/// the GPU actually finishing, so this is "how long the CPU was blocked
/// handing the frame off", not confirmed GPU time.
pub fn draw(&mut self) -> Duration {
let output = match self.surface.get_current_texture() {
CurrentSurfaceTexture::Success(texture)
| CurrentSurfaceTexture::Suboptimal(texture) => texture,
// wgpu 30 turned this Result into an enum; every arm here was an
// `Err` the previous `.unwrap()` panicked on, except `Occluded`,
// which is new.
other => panic!("no surface texture to draw into: {other:?}"),
};
let view = output
.texture
.create_view(&TextureViewDescriptor::default());
let mut encoder = std::mem::replace(&mut self.encoder, Self::create_encoder(&self.device));
{
let render_pass = &mut encoder.begin_render_pass(&RenderPassDescriptor {
color_attachments: &[Some(RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: Operations {
load: LoadOp::Clear(CLEAR_COLOR),
store: StoreOp::Store,
},
depth_slice: None,
})],
..Default::default()
});
self.ui.draw(render_pass);
}
let submit_start = Instant::now();
self.queue.submit(std::iter::once(encoder.finish()));
self.queue.present(output);
submit_start.elapsed()
}
/// Physical pixels -- the unit layout and hit-testing use, matching
/// the window uniform's own units. See
/// `android::view::AndroidUiState::content_scale`'s field comment.
pub fn size(&self) -> iris_core::util::Vec2 {
iris_core::util::Vec2::new(self.config.width as f32, self.config.height as f32)
}
/// Reconfigures the surface and rewrites the window uniform for a new
/// physical size -- deliberately the *only* two things this does.
/// `device`, `ui`'s atlas, buffers and bind groups are untouched, so a
/// call here (as opposed to a fresh `AndroidRenderer::new`) never
/// invalidates a glyph the CPU-side cache already placed in the atlas.
/// See `android::view::IrisViewPeer::surface_changed`'s doc comment for
/// why that distinction matters -- it is what keeps text on screen
/// across an IME resize.
pub fn resize(&mut self, width: u32, height: u32) {
self.config.width = width;
self.config.height = height;
self.surface.configure(&self.device, &self.config);
let size = iris_core::util::Vec2::new(width as f32, height as f32);
self.ui.resize(size, &self.queue);
}
}
/// `Tasks`' redraw handle on Android: a background task finishes on the
/// tokio thread `Tasks::init` spawned, which is not attached to the JVM, so
/// asking for a frame means attaching first. The global ref is what
/// survives past the JNI call that handed the `View` to us.
///
/// **Goes through `View::post_delayed`, not `post_frame_callback`
/// directly** -- found the hard way (RUST.md's I5 Android integration):
/// `post_frame_callback`'s Java side calls `Choreographer.getInstance()`,
/// which throws `IllegalStateException` unless the *calling* thread already
/// has a `Looper` (`Choreographer.getInstance()`'s own contract). A tokio
/// worker thread, even freshly attached to the JVM, has none -- the crash
/// was a `JavaException` inside `View::post_frame_callback`'s `.unwrap()`,
/// aborting the process on the second `redraw.request_redraw()` any
/// android transcript-screen fetch made. `View.postDelayed(Runnable, 0)`
/// is the ordinary Android answer to "queue work onto a View's own UI
/// thread from any thread" and needs no Looper of its own; `delayed_callback`
/// below is what that Runnable resolves to on the UI thread, where a real
/// `post_frame_callback` is safe again.
pub struct AndroidRedrawHandle {
vm: JavaVM,
view: GlobalRef,
}
impl AndroidRedrawHandle {
pub fn new(vm: JavaVM, view: GlobalRef) -> Self {
Self { vm, view }
}
}
impl RequestRedraw for AndroidRedrawHandle {
fn request_redraw(&self) {
let Ok(mut env) = self.vm.attach_current_thread() else {
return;
};
let local = env.new_local_ref(&self.view).unwrap();
View(local).post_delayed(&mut env, 0);
}
}
+1080
View File
File diff suppressed because it is too large. Load diff
+231
View File
@@ -0,0 +1,231 @@
use crate::prelude::*;
use std::time::{Duration, Instant};
/// What focusing a text field takes from whichever backend is running --
/// tracked here rather than duplicated per backend, since `Selector` and
/// `Selectable` (below) are the *only* thing that decides which `TextEdit`
/// is the IME's target, and both platforms need the same double-click
/// timing and the same "remember which one" bookkeeping. What differs is
/// what happens *after* the focus record is set: winit tells the
/// compositor an IME area (`focus_gained`, in `default/attr.rs`); on
/// android-view a keyboard has to be asked for explicitly, and only from a
/// JNI call this crate cannot make outside a view callback -- so
/// `focus_gained` there (`android/attr.rs`) just raises a flag the next
/// touch callback consumes. See RUST.md's I2.
pub trait FocusHost {
/// True on a click close enough in time to the previous one to grow a
/// selection instead of starting a new one, updating the clock as a
/// side effect the way a real double-click timer does.
fn recent_click(&mut self) -> bool;
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>);
/// Called on every tap that should put the IME on `id`: the tap that
/// *makes* a `TextEdit` the focus target, and any later tap on one that
/// already is. `region` is where it was hit (`None` when the widget
/// could not be located, which happens for one it was just deselected
/// from). Implementations must be idempotent -- both backends' calls
/// (`showSoftInput`, `set_ime_cursor_area`) already are, which is what
/// lets the repeat tap be handled by the same call rather than by a
/// second "re-show" entry point beside it.
fn focus_gained(&mut self, region: Option<PixelRegion>);
/// Whether `id` is the current focus target -- what [`select`] uses to
/// tell a fresh press (which must wait to see whether it becomes a tap
/// or a drag before focusing/showing the IME, Iris 2026-09-06: "if I
/// swipe over the input bar it brings up the keyboard") from a drag
/// continuing inside a field that was already focused (an ordinary
/// drag-to-select, unaffected).
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool;
}
/// Helper shared by every `FocusHost` impl, so the double-click window is
/// one constant rather than one per backend.
pub fn recent_click(last_click: &mut Instant) -> bool {
let now = Instant::now();
let recent = (now - *last_click) < Duration::from_millis(300);
*last_click = now;
recent
}
/// `PressStart`/`Pressing`/`PressEnd`, all for the left button -- what
/// [`Selector`]/[`Selectable`] register instead of [`CursorSense::
/// click_or_drag`], so their shared handler (`on_press`, below) sees every
/// frame of a gesture and can tell a completed tap from a drag itself,
/// rather than reacting to `PressStart` alone the way `click_or_drag`'s
/// consumer used to (Iris, 2026-09-06: "if I swipe over the input bar it
/// brings up the keyboard").
/// `CursorSense::Cancel` is in the set for the same reason `DragGesture`
/// registers it: if a scroll area or a list takes the pointer mid-gesture,
/// this field sees no `PressEnd`, and a `press_origin` left set is then
/// compared against the *next* press -- a stray selection, or a keyboard
/// summoned by a tap somewhere else entirely.
fn press_track() -> CursorSenses {
CursorSense::click()
| CursorSense::Pressing(CursorButton::Left)
| CursorSense::unclick()
| CursorSense::Cancel
}
pub struct Selector;
impl<Rsc: HasEvents, W: Widget + 'static> WidgetAttr<Rsc, W> for Selector
where
Rsc::State: FocusHost,
{
type Input = WeakWidget<TextEdit>;
fn run(rsc: &mut Rsc, container: WeakWidget<W>, id: Self::Input) {
rsc.register_event(container, press_track(), move |ctx, rsc| {
let region = ctx.data.render.window_region(&id, &*rsc).unwrap();
let id_pos = region.top_left;
let container_pos = ctx
.data
.render
.window_region(&container, &*rsc)
.unwrap()
.top_left;
let pos = ctx.data.pos + container_pos - id_pos;
let size = region.size();
on_press(
rsc,
ctx.data.render,
ctx.state,
id,
pos,
size,
ctx.data.sense,
);
});
}
}
pub struct Selectable;
impl<Rsc: HasEvents> WidgetAttr<Rsc, TextEdit> for Selectable
where
Rsc::State: FocusHost,
{
type Input = ();
fn run(rsc: &mut Rsc, id: WeakWidget<TextEdit>, _: Self::Input) {
rsc.register_event(id, press_track(), move |ctx, rsc| {
on_press(
rsc,
ctx.data.render,
ctx.state,
id,
ctx.data.pos,
ctx.data.size,
ctx.data.sense,
);
});
}
}
/// One press-track frame (`PressStart`, `Pressing` or `PressEnd`) over a
/// selectable field. A field that is *already* focused behaves exactly as
/// `click_or_drag` always did -- every frame updates the selection, which
/// is what lets a finger already inside a focused field drag out a
/// selection. A field that is **not** focused withholds `select`'s
/// focus-granting side effects (and so the platform-specific `focus_gained`
/// that shows the keyboard) until the press resolves as a tap: `PressEnd`
/// with no frame in between having moved past [`DRAG_SLOP`] from where the
/// press began. A drag recognised before release simply cancels the
/// pending tap and does nothing further here -- it is not consumed, so
/// whatever is behind the field (a list to pan) still sees every frame of
/// it, the same as a drag that never touched a selectable field at all.
fn on_press(
rsc: &mut impl UiRsc,
render: &UiRenderState,
state: &mut impl FocusHost,
id: WeakWidget<TextEdit>,
pos: Vec2,
size: Vec2,
sense: CursorSense,
) {
if state.is_focused(id) {
// Already focused, so there is no keyboard to withhold -- but a
// vertical drag still is not a selection. Android's own `EditText`
// scrolls its overflowed text on a vertical drag and starts a
// selection only from a long press; a scroll area wrapping this
// field (`ScrollController::drag`) is what actually pans, and it needs the
// first frames of the gesture not to have selected anything behind
// it before it crosses `DRAG_SLOP` and takes pointer capture.
// `press_origin` carries the same meaning here as in the unfocused
// branch below -- "this gesture is still eligible", cleared the
// moment it becomes a drag -- so there is one flag, not two.
match sense {
CursorSense::PressStart(_) => {
let recent = state.recent_click();
id.edit(rsc).text.press_origin = Some(pos);
id.edit(rsc).select(pos, size, false, recent);
}
CursorSense::Pressing(_) | CursorSense::PressEnd(_) => {
let mut ctx = id.edit(rsc);
let Some(origin) = ctx.text.press_origin else {
return;
};
let (dx, dy) = (pos.x - origin.x, pos.y - origin.y);
if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
ctx.text.press_origin = None;
return;
}
let ended = matches!(sense, CursorSense::PressEnd(_));
if ended {
ctx.text.press_origin = None;
}
ctx.select(pos, size, true, false);
// A tap on a field that is *already* focused asks for the
// keyboard again (Iris's phone, 2026-09-06: "I can't reopen
// keyboard by tapping on message box after it already
// happened once"). Dismissing the IME -- back gesture, or
// its own hide button -- takes the keyboard away but leaves
// the field focused, so without this the one branch that
// requests it (the unfocused one below) never runs again
// and the field is permanently unable to summon it.
// Android's own `EditText` does exactly this: every tap on
// a focused field calls `showSoftInput`, which is a no-op
// when the keyboard is already up.
//
// Gated on the same tap-vs-drag test the unfocused branch
// uses, not on `PressEnd` alone, so a drag-to-select that
// happens to finish inside the field does not summon a
// keyboard the reader was not asking for.
if ended && dx.abs() <= DRAG_SLOP && dy.abs() <= DRAG_SLOP {
state.focus_gained(render.window_region(&id, &*rsc));
}
}
CursorSense::Cancel => id.edit(rsc).text.press_origin = None,
_ => {}
}
return;
}
match sense {
CursorSense::PressStart(_) => {
id.edit(rsc).text.press_origin = Some(pos);
}
CursorSense::Pressing(_) => {
let ctx = id.edit(rsc);
if let Some(origin) = ctx.text.press_origin
&& ((pos.x - origin.x).abs() > DRAG_SLOP || (pos.y - origin.y).abs() > DRAG_SLOP)
{
// Past the slop before release: this is a drag, not a tap
// -- give up the pending focus rather than granting it once
// the finger lifts wherever it happens to be by then.
ctx.text.press_origin = None;
}
}
// The gesture was taken by somebody else, so it is not a tap and
// must not grant focus when it ends out of this widget's sight.
CursorSense::Cancel => id.edit(rsc).text.press_origin = None,
CursorSense::PressEnd(_) => {
let was_tap = id.edit(rsc).text.press_origin.take().is_some();
if was_tap {
let recent = state.recent_click();
id.edit(rsc).select(pos, size, false, recent);
state.set_focus(Some(id));
state.focus_gained(render.window_region(&id, &*rsc));
}
}
_ => {}
}
}
+28
View File
@@ -0,0 +1,28 @@
//! I4 (RUST.md): the desktop half of the AccessKit push, over
//! `accesskit_winit`. `bench-lib.sh`'s tap-by-name goes through the
//! platform's real accessibility tree, so this crate only has to keep that
//! tree in sync with `ui::access::AccessTree`'s output -- nothing here
//! reacts to an AccessKit action request, which is why the three handlers
//! below are inert. See RUST.md's I4 box for why: on Android (and, by the
//! same platform convention, everywhere else) a screen reader's element tap
//! is a real touch delivered at the node's own bounds, not an action
//! request synthesised in-process -- so the ordinary pointer path already
//! handles it once the bounds are right.
use accesskit::{ActionHandler, ActionRequest, ActivationHandler, DeactivationHandler, TreeUpdate};
pub struct NullActivationHandler;
impl ActivationHandler for NullActivationHandler {
fn request_initial_tree(&mut self) -> Option<TreeUpdate> {
None
}
}
pub struct NullActionHandler;
impl ActionHandler for NullActionHandler {
fn do_action(&mut self, _request: ActionRequest) {}
}
pub struct NullDeactivationHandler;
impl DeactivationHandler for NullDeactivationHandler {
fn deactivate_accessibility(&mut self) {}
}
+4
View File
@@ -27,6 +27,10 @@ pub struct App<State: AppState> {
impl<State: AppState> App<State> { impl<State: AppState> App<State> {
pub fn run() { 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 event_loop = EventLoop::with_user_event().build().unwrap();
let proxy = event_loop.create_proxy(); let proxy = event_loop.create_proxy();
event_loop event_loop
+17 -67
View File
@@ -1,78 +1,28 @@
use crate::prelude::*; use crate::prelude::*;
use std::time::{Duration, Instant}; use winit::dpi::{PhysicalPosition, PhysicalSize};
use winit::dpi::{LogicalPosition, LogicalSize};
pub struct Selector; impl<T: HasDefaultUiState> FocusHost for T {
fn recent_click(&mut self) -> bool {
impl<Rsc: HasEvents, W: Widget + 'static> WidgetAttr<Rsc, W> for Selector crate::attr::recent_click(&mut self.default_state_mut().last_click)
where
Rsc::State: HasDefaultUiState,
{
type Input = WeakWidget<TextEdit>;
fn run(rsc: &mut Rsc, container: WeakWidget<W>, id: Self::Input) {
rsc.register_event(container, CursorSense::click_or_drag(), move |ctx, rsc| {
let region = ctx.data.render.window_region(&id).unwrap();
let id_pos = region.top_left;
let container_pos = ctx.data.render.window_region(&container).unwrap().top_left;
let pos = ctx.data.pos + container_pos - id_pos;
let size = region.size();
select(
rsc,
ctx.data.render,
ctx.state,
id,
pos,
size,
ctx.data.sense.is_dragging(),
);
});
}
} }
pub struct Selectable; fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
self.default_state_mut().focus = id;
impl<Rsc: HasEvents> WidgetAttr<Rsc, TextEdit> for Selectable
where
Rsc::State: HasDefaultUiState,
{
type Input = ();
fn run(rsc: &mut Rsc, id: WeakWidget<TextEdit>, _: Self::Input) {
rsc.register_event(id, CursorSense::click_or_drag(), move |ctx, rsc| {
select(
rsc,
ctx.data.render,
ctx.state,
id,
ctx.data.pos,
ctx.data.size,
ctx.data.sense.is_dragging(),
);
});
}
} }
fn select( fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
rsc: &mut impl UiRsc, self.default_state().focus == Some(id)
render: &UiRenderState, }
state: &mut impl HasDefaultUiState,
id: WeakWidget<TextEdit>, fn focus_gained(&mut self, region: Option<PixelRegion>) {
pos: Vec2, let state = self.default_state_mut();
size: Vec2, let Some(region) = region else { return };
dragging: bool,
) {
let state = state.default_state_mut();
let now = Instant::now();
let recent = (now - state.last_click) < Duration::from_millis(300);
state.last_click = now;
id.edit(rsc).select(pos, size, dragging, recent);
if let Some(region) = render.window_region(&id) {
state.window.set_ime_allowed(true); state.window.set_ime_allowed(true);
// Physical, like everything else this backend hands winit --
// `default::content_scale`.
state.window.set_ime_cursor_area( state.window.set_ime_cursor_area(
LogicalPosition::<f32>::from(region.top_left.tuple()), PhysicalPosition::<f32>::from(region.top_left.tuple()),
LogicalSize::<f32>::from(region.size().tuple()), PhysicalSize::<f32>::from(region.size().tuple()),
); );
} }
state.focus = Some(id);
} }
-9
View File
@@ -1,9 +0,0 @@
use iris_core::Event;
#[derive(Eq, PartialEq, Hash, Clone)]
pub struct Submit;
impl Event for Submit {}
#[derive(Eq, PartialEq, Hash, Clone)]
pub struct Edited;
impl Event for Edited {}
+17 -1
View File
@@ -1,4 +1,10 @@
// `CursorState::time` is the sample's own time on every backend. winit
// carries no timestamp on a pointer event, so the moment it is handed to
// us is the closest measurement available here -- which is also what the
// drag code used to do for itself with `Instant::now()`, before Android's
// batched samples made the difference matter (see `sense::CursorState`).
use crate::prelude::*; use crate::prelude::*;
use std::time::Instant;
use winit::{ use winit::{
event::{MouseButton, MouseScrollDelta, WindowEvent}, event::{MouseButton, MouseScrollDelta, WindowEvent},
keyboard::{Key, NamedKey}, keyboard::{Key, NamedKey},
@@ -11,13 +17,19 @@ pub struct Input {
} }
impl Input { impl Input {
/// winit's pointer coordinates are physical pixels, which is the
/// space the whole tree is laid out and hit-tested in -- see
/// `default::content_scale`. Nothing is converted here; `dp(...)`
/// resolves against the density at layout time instead.
pub fn event(&mut self, event: &WindowEvent) -> bool { pub fn event(&mut self, event: &WindowEvent) -> bool {
match event { match event {
WindowEvent::CursorMoved { position, .. } => { WindowEvent::CursorMoved { position, .. } => {
self.cursor.pos = Vec2::new(position.x as f32, position.y as f32); self.cursor.pos = Vec2::new(position.x as f32, position.y as f32);
self.cursor.exists = true; self.cursor.exists = true;
self.cursor.time = Instant::now();
} }
WindowEvent::MouseInput { state, button, .. } => { WindowEvent::MouseInput { state, button, .. } => {
self.cursor.time = Instant::now();
let buttons = &mut self.cursor.buttons; let buttons = &mut self.cursor.buttons;
let pressed = state.is_pressed(); let pressed = state.is_pressed();
match button { match button {
@@ -37,6 +49,7 @@ impl Input {
delta.y = 0.0; delta.y = 0.0;
} }
self.cursor.scroll_delta = delta; self.cursor.scroll_delta = delta;
self.cursor.time = Instant::now();
} }
WindowEvent::CursorLeft { .. } => { WindowEvent::CursorLeft { .. } => {
self.cursor.exists = false; self.cursor.exists = false;
@@ -67,9 +80,12 @@ impl Input {
} }
impl DefaultUiState { impl DefaultUiState {
/// Physical pixels, matching `WindowEvent::Resized` (what
/// `UiRenderState::resize` is given) and the swapchain -- see
/// `default::content_scale`.
pub fn window_size(&self) -> Vec2 { pub fn window_size(&self) -> Vec2 {
let size = self.renderer.window().inner_size(); let size = self.renderer.window().inner_size();
(size.width, size.height).into() Vec2::new(size.width as f32, size.height as f32)
} }
pub fn cursor_state(&self) -> &CursorState { pub fn cursor_state(&self) -> &CursorState {
+86
View File
@@ -0,0 +1,86 @@
//! A stderr logger for the desktop entry point.
//!
//! Without one, `log::` calls on this side go nowhere: `log`'s default is
//! a no-op logger, and nothing in `desktop-app` or the examples ever
//! installed a real one. That is how iris came to have a renderer that
//! silently fell back to GLES (and, on this VM, on to llvmpipe when the
//! host took its GPU away) with **no record anywhere of what
//! drew the frame** -- a layer-2 screenshot off llvmpipe and one off the
//! host GPU are the same PNG, and the difference is exactly what a
//! screenshot is being taken to judge.
//!
//! Installed by [`DefaultApp::run`](super::app::DefaultApp::run) rather
//! than by a library call somewhere, because that function already takes
//! over the process -- it owns the event loop and does not return -- so
//! it is the desktop's `main` in everything but name, and one install
//! there covers `desktop-app` and every example at once. `try_init`
//! rather than `init`: a binary that installed its own logger first keeps
//! it, and a second `DefaultApp::run` in one process is not an error.
//!
//! Deliberately not `env_logger`. All this owes the reader is a level and
//! a line, which is a page of code against a dependency plus its own
//! filter dialect; the Android side is `android_logger` for the same
//! reason -- one line per platform's own convention.
use std::io::Write;
use log::{Level, LevelFilter, Log, Metadata, Record};
/// Reads one level name from `RUST_LOG` -- `off`, `error`, `warn`,
/// `info`, `debug`, `trace`, case-insensitively. **Not env_logger's
/// per-module filter syntax**: anything else is ignored and the default
/// stands, rather than being silently read as "off", since a typo that
/// turned logging off would be indistinguishable from a quiet program.
fn level_from_env(default: LevelFilter) -> LevelFilter {
match std::env::var("RUST_LOG") {
Ok(text) => text.trim().parse().unwrap_or(default),
Err(_) => default,
}
}
struct StderrLogger {
level: LevelFilter,
}
impl Log for StderrLogger {
fn enabled(&self, metadata: &Metadata) -> bool {
metadata.level() <= self.level
}
fn log(&self, record: &Record) {
if !self.enabled(record.metadata()) {
return;
}
// One write, not a `writeln!` per part: two threads logging at
// once interleave otherwise, and the frame and input traces are
// both written from whichever thread produced them.
let line = format!(
"{level:<5} {target}: {args}\n",
level = match record.level() {
Level::Error => "ERROR",
Level::Warn => "WARN",
Level::Info => "INFO",
Level::Debug => "DEBUG",
Level::Trace => "TRACE",
},
target = record.target(),
args = record.args(),
);
let _ = std::io::stderr().write_all(line.as_bytes());
}
fn flush(&self) {
let _ = std::io::stderr().flush();
}
}
/// Installs the stderr logger unless this process already has one.
/// Defaults to `info`, which is where the renderer says which adapter it
/// got; `RUST_LOG=debug` adds iris's own per-frame lines.
pub fn install(default: LevelFilter) {
let level = level_from_env(default);
let logger = Box::leak(Box::new(StderrLogger { level }));
if log::set_logger(logger).is_ok() {
log::set_max_level(level);
}
}
+145 -43
View File
@@ -11,26 +11,53 @@ use winit::{
window::{Window, WindowAttributes}, window::{Window, WindowAttributes},
}; };
mod access;
mod app; mod app;
mod attr; mod attr;
mod event;
mod input; mod input;
mod logging;
mod platform;
mod render; mod render;
mod sense;
mod state;
mod task;
pub use access::*;
pub use app::*; pub use app::*;
pub use attr::*;
pub use event::*;
pub use input::*; pub use input::*;
pub use render::*; pub use render::*;
pub use sense::*;
pub use state::*;
pub use task::*;
pub type Proxy<Event> = EventLoopProxy<Event>; pub type Proxy<Event> = EventLoopProxy<Event>;
/// The desktop's `content_scale`: physical pixels per dp, the same
/// quantity Android reads from `DisplayMetrics.density` and feeds to
/// `UiRenderState::set_density` (`android::view::AndroidUiState::
/// content_scale`'s field comment). Everything in this backend is
/// physical pixels -- the window size, the pointer, the widget tree --
/// and `dp(...)` is what resolves against this at layout time, exactly
/// as on the phone. That is a correction from an earlier version that
/// divided winit's coordinates into a separate "logical" space instead:
/// it left `UiRenderState::resize` (physical, from `WindowEvent::
/// Resized`) and the window uniform (logical) disagreeing on any
/// display whose scale factor is not 1.0, and it rasterised glyphs at
/// one resolution to display them at another -- the blur the phone's own
/// stopgap produced before `dp` existed.
///
/// **`IRIS_SCALE` overrides it**, which is how a phone-shaped desktop
/// window runs the phone's density (`run-headless.sh --phone`,
/// docs/RUST.md's layer 2). An unparsable value is a typo in a command
/// somebody just typed, so it says so and uses the window's own answer
/// rather than silently laying out at the wrong density.
pub fn content_scale(window: &Window) -> f32 {
match std::env::var("IRIS_SCALE") {
Err(_) => window.scale_factor() as f32,
Ok(text) => match text.trim().parse::<f32>() {
Ok(scale) if scale > 0.0 => scale,
_ => {
log::warn!("IRIS_SCALE={text:?} is not a positive number; using the window's own");
window.scale_factor() as f32
}
},
}
}
pub struct DefaultUiState { pub struct DefaultUiState {
pub root: Option<StrongWidget>, pub root: Option<StrongWidget>,
pub renderer: UiRenderer, pub renderer: UiRenderer,
@@ -40,6 +67,17 @@ pub struct DefaultUiState {
pub window: Arc<Window>, pub window: Arc<Window>,
pub ime: usize, pub ime: usize,
pub last_click: Instant, pub last_click: Instant,
/// I4 (RUST.md): pushed through in `DefaultApp::window_event`'s
/// `RedrawRequested` arm, from `access`'s output. Built in
/// `DefaultApp::new`, which is the only place with the
/// `&ActiveEventLoop` `accesskit_winit::Adapter::with_direct_handlers`
/// needs -- see that constructor's doc comment on why the window must
/// still be invisible when it is called.
pub access_adapter: accesskit_winit::Adapter,
/// The AccessKit tree itself -- see `iris_core::AccessTree`'s doc
/// comment for the flat shape and why it only rebuilds on a real
/// change.
pub access: AccessTree,
} }
impl HasRoot for DefaultUiState { impl HasRoot for DefaultUiState {
@@ -49,7 +87,7 @@ impl HasRoot for DefaultUiState {
} }
impl DefaultUiState { impl DefaultUiState {
pub fn new(window: impl Into<Arc<Window>>) -> Self { pub fn new(window: impl Into<Arc<Window>>, access_adapter: accesskit_winit::Adapter) -> Self {
let window = window.into(); let window = window.into();
Self { Self {
root: None, root: None,
@@ -60,6 +98,8 @@ impl DefaultUiState {
ime: 0, ime: 0,
last_click: Instant::now(), last_click: Instant::now(),
focus: None, focus: None,
access_adapter,
access: AccessTree::new(),
} }
} }
} }
@@ -188,13 +228,35 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
type Event = State::Event; type Event = State::Event;
fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy<Self::Event>) -> Self { 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 let window = event_loop
.create_window(State::window_attributes()) .create_window(State::window_attributes().with_visible(false))
.unwrap(); .unwrap();
let default_state = DefaultUiState::new(window); let access_adapter = accesskit_winit::Adapter::with_direct_handlers(
event_loop,
&window,
NullActivationHandler,
NullActionHandler,
NullDeactivationHandler,
);
window.set_visible(true);
let default_state = DefaultUiState::new(window, access_adapter);
let (mut rsc, task_recv) = DefaultRsc::init(default_state.window.clone()); let (mut rsc, task_recv) = DefaultRsc::init(default_state.window.clone());
// Both copies of the density, set before the first widget is
// built so text shapes at the right size on the opening frame --
// the same pair `android::view::new_peer` sets from
// `content_scale`. See `iris_core::TextData::density` for why the
// shaper keeps its own.
let scale = content_scale(default_state.window.as_ref());
rsc.ui.text.density = scale;
let state = State::new(default_state, &mut rsc, proxy); let state = State::new(default_state, &mut rsc, proxy);
let render = UiRenderState::new(); let mut render = UiRenderState::new();
render.set_density(scale);
Self { Self {
rsc, rsc,
state, state,
@@ -220,6 +282,12 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
} }
let ui_state = state.default_state_mut(); let ui_state = state.default_state_mut();
// Required by `accesskit_winit` on every window event, not just the
// ones this backend otherwise cares about -- some platform adapters
// rely on it to notice activation (a screen reader turning on).
ui_state
.access_adapter
.process_event(&ui_state.window, &event);
let input_changed = ui_state.input.event(&event); let input_changed = ui_state.input.event(&event);
let cursor_state = ui_state.cursor_state().clone(); let cursor_state = ui_state.cursor_state().clone();
let old = ui_state.focus; let old = ui_state.focus;
@@ -227,6 +295,31 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
ui_state.focus = None; ui_state.focus = None;
} }
if input_changed { if input_changed {
// The winit half of `iris::input` (`sense::log_input_event`'s
// own doc): no batching here, so `historical` is always empty
// -- winit hands one `WindowEvent` per pointer sample, unlike
// Android's `MotionEvent`. The action is read back off the
// buttons `Input::event` just updated, the same test
// `GestureOutcome`'s callers already use to tell a press from a
// release. Computed only when tracing is on, same reasoning as
// `log_input_event` itself gating on it.
if crate::diagnostics::trace_enabled() {
let action = if cursor_state.buttons.left.is_start() {
"down"
} else if cursor_state.buttons.left.is_end() {
"up"
} else {
"move"
};
let t_ms = cursor_state.time.duration_since(render.epoch()).as_millis() as u64;
crate::sense::log_input_event(
action,
cursor_state.pos.x,
cursor_state.pos.y,
t_ms,
&[],
);
}
let window_size = ui_state.window_size(); let window_size = ui_state.window_size();
render.run_sensors(rsc, state, cursor_state, window_size); render.run_sensors(rsc, state, cursor_state, window_size);
} }
@@ -239,14 +332,53 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
match &event { match &event {
WindowEvent::CloseRequested => event_loop.exit(), WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::RedrawRequested => { WindowEvent::RedrawRequested => {
// Before the draw, so this frame shows this instant's
// position (`UiData::tick_animations`' own doc), and the
// window is asked for another frame while anything is
// still moving -- the winit half of what
// `IrisViewPeer::render`'s `post_frame_callback` does on
// Android. Nothing else in iris moves without an input
// event.
let frame_start = std::time::Instant::now();
let animating = rsc.ui_mut().tick_animations(frame_start);
let ui_state = state.default_state_mut();
render.update(&ui_state.root, rsc); render.update(&ui_state.root, rsc);
ui_state.renderer.update(&mut rsc.ui, render); ui_state.renderer.update(&mut rsc.ui, render);
let draw_start = std::time::Instant::now();
ui_state.renderer.draw(); ui_state.renderer.draw();
crate::diagnostics::log_frame(render, frame_start, draw_start.elapsed(), animating);
if animating {
ui_state.window.request_redraw();
}
// I4 (RUST.md): only produces a `TreeUpdate` when the named
// set actually changed this frame -- see `AccessTree`'s doc
// comment. `render` reflects the draw that just happened,
// so `resolved_region`/`window_region` inside it report a
// moved subtree's *new* position, not last frame's.
if let Some(tree_update) = ui_state.access.update(rsc.widgets(), render, rsc) {
ui_state.access_adapter.update_if_active(|| tree_update);
}
} }
WindowEvent::Resized(size) => { WindowEvent::Resized(size) => {
render.resize((size.width, size.height)); render.resize((size.width, size.height));
ui_state.renderer.resize(size) ui_state.renderer.resize(size)
} }
// Dragging the window to a display with a different scale.
// Both copies again, the pair `new` sets at startup -- read
// through `content_scale` rather than from the event, so
// `IRIS_SCALE` still pins the density it was given (the
// `--phone` window must not follow the monitor). winit sends
// the matching `Resized` separately. Before 2026-09-07 this
// event was unhandled, so every `dp` and every rasterised
// glyph stayed at the density the window opened on
// (docs/REVIEW-2026-09-07.md's R5) -- invisible on this
// machine, where every display is 1.0.
WindowEvent::ScaleFactorChanged { .. } => {
let scale = content_scale(ui_state.window.as_ref());
rsc.ui.text.density = scale;
render.set_density(scale);
ui_state.window.request_redraw();
}
WindowEvent::KeyboardInput { event, .. } => { WindowEvent::KeyboardInput { event, .. } => {
if let Some(sel) = ui_state.focus if let Some(sel) = ui_state.focus
&& event.state.is_pressed() && event.state.is_pressed()
@@ -309,12 +441,6 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
} }
} }
pub trait RscIdx<Rsc> {
type Output;
fn get(self, rsc: &Rsc) -> &Self::Output;
fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output;
}
impl<State: 'static, I: RscIdx<DefaultRsc<State>>> std::ops::Index<I> for DefaultRsc<State> { impl<State: 'static, I: RscIdx<DefaultRsc<State>>> std::ops::Index<I> for DefaultRsc<State> {
type Output = I::Output; type Output = I::Output;
@@ -328,27 +454,3 @@ impl<State: 'static, I: RscIdx<DefaultRsc<State>>> std::ops::IndexMut<I> for Def
index.get_mut(self) index.get_mut(self)
} }
} }
impl<W: Widget, Rsc: UiRsc> RscIdx<Rsc> for WeakWidget<W> {
type Output = W;
fn get(self, rsc: &Rsc) -> &Self::Output {
&rsc.ui().widgets[self]
}
fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output {
&mut rsc.ui_mut().widgets[self]
}
}
impl<T: 'static, Rsc: HasWidgetState> RscIdx<Rsc> for WeakState<T> {
type Output = T;
fn get(self, rsc: &Rsc) -> &Self::Output {
rsc.widget_state().get(self)
}
fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output {
rsc.widget_state_mut().get_mut(self)
}
}
+33
View File
@@ -0,0 +1,33 @@
use crate::platform::OpenUrl;
use crate::prelude::HasDefaultUiState;
/// The desktop's URL opener: the platform's own "open this with whatever
/// is registered for it" command, detached so a browser starting slowly
/// cannot stall the event loop.
///
/// A command rather than a crate: `xdg-open`/`open`/`start` is what every
/// such crate shells out to anyway, and this is one call site.
impl<T: HasDefaultUiState> OpenUrl for T {
fn open_url(&mut self, url: &str) {
let (program, first): (&str, &[&str]) = if cfg!(target_os = "macos") {
("open", &[])
} else if cfg!(target_os = "windows") {
// `start` is a shell builtin, and its first argument is the
// window title -- an empty one, or a URL containing `&` ends
// up split.
("cmd", &["/C", "start", ""])
} else {
("xdg-open", &[])
};
match std::process::Command::new(program)
.args(first)
.arg(url)
.spawn()
{
Ok(_) => {}
// Named with the command that failed and the link it was for,
// since neither is recoverable from the OS error alone.
Err(e) => log::warn!("could not open {url} with {program}: {e}"),
}
}
}
+125 -22
View File
@@ -1,4 +1,5 @@
use iris_core::{UiData, UiLimits, UiRenderNode, UiRenderState}; use crate::task::RequestRedraw;
use iris_core::{UiData, UiRenderNode, UiRenderState, util::Vec2};
use pollster::FutureExt; use pollster::FutureExt;
use std::sync::Arc; use std::sync::Arc;
use wgpu::*; use wgpu::*;
@@ -6,6 +7,12 @@ use winit::{dpi::PhysicalSize, window::Window};
pub const CLEAR_COLOR: Color = Color::BLACK; pub const CLEAR_COLOR: Color = Color::BLACK;
impl RequestRedraw for Window {
fn request_redraw(&self) {
Window::request_redraw(self);
}
}
pub struct UiRenderer { pub struct UiRenderer {
window: Arc<Window>, window: Arc<Window>,
surface: Surface<'static>, surface: Surface<'static>,
@@ -22,7 +29,16 @@ impl UiRenderer {
} }
pub fn draw(&mut self) { pub fn draw(&mut self) {
let output = self.surface.get_current_texture().unwrap(); let output = match self.surface.get_current_texture() {
CurrentSurfaceTexture::Success(texture)
| CurrentSurfaceTexture::Suboptimal(texture) => texture,
// wgpu 30 turned this Result into an enum; every arm here was an
// `Err` the previous `.unwrap()` panicked on, except `Occluded`,
// which is new. Named rather than swallowed: a window that stops
// presenting silently is the state this file's `pre_present_notify`
// comment was written about.
other => panic!("no surface texture to draw into: {other:?}"),
};
let view = output let view = output
.texture .texture
.create_view(&TextureViewDescriptor::default()); .create_view(&TextureViewDescriptor::default());
@@ -45,14 +61,25 @@ impl UiRenderer {
} }
self.queue.submit(std::iter::once(encoder.finish())); self.queue.submit(std::iter::once(encoder.finish()));
output.present(); // Immediately before presenting, so the windowing system can schedule
// the frame. On Wayland this is what ties the commit to the surface's
// frame callback; without it a frame drawn when nothing else follows
// could sit unpresented, and the window kept the layout it had before
// the compositor's first resize -- intermittently, on about a fifth of
// starts, with nothing left to flush it.
self.window.pre_present_notify();
self.queue.present(output);
} }
pub fn resize(&mut self, size: &PhysicalSize<u32>) { pub fn resize(&mut self, size: &PhysicalSize<u32>) {
self.config.width = size.width; self.config.width = size.width;
self.config.height = size.height; self.config.height = size.height;
self.surface.configure(&self.device, &self.config); self.surface.configure(&self.device, &self.config);
self.ui.resize(size, &self.queue); // Physical, matching `new`'s own seed -- see the comment there.
self.ui.resize(
Vec2::new(size.width as f32, size.height as f32),
&self.queue,
);
} }
fn create_encoder(device: &Device) -> CommandEncoder { fn create_encoder(device: &Device) -> CommandEncoder {
@@ -64,10 +91,44 @@ impl UiRenderer {
pub fn new(window: Arc<Window>) -> Self { pub fn new(window: Arc<Window>) -> Self {
let size = window.inner_size(); let size = window.inner_size();
let instance = Instance::new(&InstanceDescriptor { // `force-gles` on the desktop too, not just on Android: the
backends: Backends::PRIMARY, // GLES backend has behaviour of its own (a one-layer array
..Default::default() // texture is a `GL_TEXTURE_2D` -- see
// `GpuTextures::create_array_texture`), and a machine with a
// real GPU is where that is cheap to reproduce and screenshot.
let mut backends = if cfg!(feature = "force-gles") {
Backends::GL
} else {
Backends::PRIMARY
};
// The display handle comes from the window rather than being left
// out: wgpu 30 asks for it whenever a GLES surface is going to be
// presented on Wayland, which is exactly what the fallback below
// produces on this machine.
let mut instance = Instance::new(InstanceDescriptor {
backends,
..InstanceDescriptor::new_with_display_handle(Box::new(window.clone()))
}); });
// The same fallback the Android backend grew in 85869d0, and for
// the same reason: a machine can advertise a Vulkan ICD with no
// device behind it, and refusing to draw at all because the only
// usable adapter is a GLES one is iris's bug rather than the
// machine's. On this VM the Vulkan device disappears whenever
// the host refuses a virtio-gpu context, so `run-headless.sh` --
// layer 2 of the test rig -- aborted with `Could not get
// adapter!` while GL was sitting there working. Probed before the
// surface exists, matching Android, where an instance carrying
// both backends fails worse than one carrying the wrong one.
if backends != Backends::GL && instance.enumerate_adapters(backends).block_on().is_empty() {
log::warn!(
"iris renderer: no {backends:?} adapter on this machine, falling back to GLES"
);
backends = Backends::GL;
instance = Instance::new(InstanceDescriptor {
backends,
..InstanceDescriptor::new_with_display_handle(Box::new(window.clone()))
});
}
let surface = instance let surface = instance
.create_surface(window.clone()) .create_surface(window.clone())
@@ -78,25 +139,48 @@ impl UiRenderer {
power_preference: PowerPreference::default(), power_preference: PowerPreference::default(),
compatible_surface: Some(&surface), compatible_surface: Some(&surface),
force_fallback_adapter: false, force_fallback_adapter: false,
..Default::default()
}) })
.block_on() .block_on()
.expect("Could not get adapter!"); .unwrap_or_else(|error| {
panic!("No usable GPU adapter for backends {backends:?}: {error}")
});
let ui_limits = UiLimits::default(); // Say which adapter won, in the same words the Android backend
// uses. Without it a layer-2 screenshot or frame time from this
// window carries no record of what drew it, and the two cases that
// matter look identical in the PNG: the host's real GPU, and
// llvmpipe after this VM lost its virtio-gpu contexts. That
// happened on 2026-09-08, and the only reason anyone noticed is
// that the fallback above did not exist yet and the app aborted
// instead. A silent fallback needs this line to stay honest.
{
let info = adapter.get_info();
log::info!(
"iris renderer: {name} ({backend:?}, {driver}{driver_info}) on {backends:?}",
name = info.name,
backend = info.backend,
driver = info.driver,
driver_info = if info.driver_info.is_empty() {
String::new()
} else {
format!(" {}", info.driver_info)
},
);
}
// No features beyond what wgpu asks for by default, and no
// binding-array limits: the atlas is one texture_2d_array and a
// standalone image is its own ordinary bind group, neither of which
// needs descriptor indexing. See TEXTURES.md's "Recommended shape"
// for why the old binding array asked for
// VK_EXT_descriptor_indexing unconditionally and did not survive a
// real share of Android GPUs. `iris_core::device_limits()` is
// shared with the Android backend; see its own doc for why it is
// not simply `Limits::default()`.
let (device, queue) = adapter let (device, queue) = adapter
.request_device(&DeviceDescriptor { .request_device(&DeviceDescriptor {
required_features: Features::TEXTURE_BINDING_ARRAY required_limits: iris_core::device_limits(),
| Features::PARTIALLY_BOUND_BINDING_ARRAY
| Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING,
required_limits: Limits {
max_binding_array_elements_per_shader_stage: ui_limits
.max_binding_array_elements_per_shader_stage(),
max_binding_array_sampler_elements_per_shader_stage: ui_limits
.max_binding_array_sampler_elements_per_shader_stage(),
max_buffer_size: 1 << 30,
..Default::default()
},
..Default::default() ..Default::default()
}) })
.block_on() .block_on()
@@ -113,9 +197,16 @@ impl UiRenderer {
let config = SurfaceConfiguration { let config = SurfaceConfiguration {
usage: TextureUsages::RENDER_ATTACHMENT, usage: TextureUsages::RENDER_ATTACHMENT,
format: surface_format, format: surface_format,
// wgpu 30's new field; `Auto` is what every earlier version did.
color_space: SurfaceColorSpace::Auto,
width: size.width, width: size.width,
height: size.height, height: size.height,
present_mode: PresentMode::AutoNoVsync, // Vsync, because a toolkit aiming at battery life must not present
// frames a display will never show: AutoNoVsync accepts them as
// fast as the GPU will take them, so a redraw burst costs whatever
// the hardware can be made to do rather than one frame.
// AutoVsync picks Fifo, which every backend supports.
present_mode: PresentMode::AutoVsync,
alpha_mode: surface_caps.alpha_modes[0], alpha_mode: surface_caps.alpha_modes[0],
desired_maximum_frame_latency: 2, desired_maximum_frame_latency: 2,
view_formats: vec![], view_formats: vec![],
@@ -125,7 +216,19 @@ impl UiRenderer {
let encoder = Self::create_encoder(&device); let encoder = Self::create_encoder(&device);
let ui = UiRenderNode::new(&device, &queue, &config, ui_limits); // Unlike the Android backend, the desktop backend has no on-screen
// fallback to show a diagnostic through, so a renderer-creation
// failure still panics here -- but now with wgpu's full "Caused
// by:" chain as the message, since `UiRenderNode::new` returns it
// rather than letting wgpu's own default handler panic first (see
// that function's doc comment).
// Physical size, the same units the swapchain, `WindowEvent::
// Resized`, the pointer and the widget tree all use -- see
// `default::content_scale` for why this backend stopped dividing
// into a separate logical space, and what disagreed while it did.
let physical_size = Vec2::new(size.width as f32, size.height as f32);
let ui = UiRenderNode::new(&device, &queue, &config, physical_size)
.expect("Could not create iris render node!");
Self { Self {
surface, surface,
-308
View File
@@ -1,308 +0,0 @@
use crate::prelude::*;
use std::{
ops::{BitOr, Deref, DerefMut},
rc::Rc,
};
#[derive(Clone, Copy, PartialEq)]
pub enum CursorButton {
Left,
Right,
Middle,
}
#[derive(Clone, Copy, PartialEq)]
pub enum CursorSense {
PressStart(CursorButton),
Pressing(CursorButton),
PressEnd(CursorButton),
HoverStart,
Hovering,
HoverEnd,
Scroll,
}
#[derive(Clone)]
pub struct CursorSenses(Vec<CursorSense>);
impl Event for CursorSenses {
type Data<'a> = CursorData<'a>;
type State = SensorState;
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
if let Some(sense) = should_run(self, &data.cursor, data.hover) {
let mut data = data.clone();
data.sense = sense;
Some(data)
} else {
None
}
}
}
impl CursorSense {
pub fn click() -> Self {
Self::PressStart(CursorButton::Left)
}
pub fn click_or_drag() -> CursorSenses {
Self::click() | Self::Pressing(CursorButton::Left)
}
pub fn unclick() -> Self {
Self::PressEnd(CursorButton::Left)
}
pub fn is_dragging(&self) -> bool {
matches!(self, CursorSense::Pressing(CursorButton::Left))
}
}
#[derive(Default, Clone)]
pub struct CursorState {
pub pos: Vec2,
pub exists: bool,
pub buttons: CursorButtons,
pub scroll_delta: Vec2,
}
#[derive(Default, Clone)]
pub struct CursorButtons {
pub left: ActivationState,
pub middle: ActivationState,
pub right: ActivationState,
}
impl CursorButtons {
pub fn select(&self, button: &CursorButton) -> &ActivationState {
match button {
CursorButton::Left => &self.left,
CursorButton::Right => &self.right,
CursorButton::Middle => &self.middle,
}
}
pub fn end_frame(&mut self) {
self.left.end_frame();
self.middle.end_frame();
self.right.end_frame();
}
pub fn iter(&self) -> impl Iterator<Item = (CursorButton, &ActivationState)> {
[
CursorButton::Left,
CursorButton::Middle,
CursorButton::Right,
]
.into_iter()
.map(|b| (b, self.select(&b)))
}
}
impl CursorState {
pub fn end_frame(&mut self) {
self.buttons.end_frame();
self.scroll_delta = Vec2::ZERO;
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub enum ActivationState {
Start,
On,
End,
#[default]
Off,
}
/// this and other similar stuff has a generic
/// because I kind of want to make CursorModule generic
/// or basically have some way to have custom senses
/// that depend on active widget positions
/// but I'm not sure how or if worth it
pub struct Sensor<Ctx: HasEvents, Data> {
pub senses: CursorSenses,
pub f: Rc<dyn EventFn<Ctx, Data>>,
}
pub type SenseShape = UiRegion;
#[derive(Default, Debug)]
pub struct SensorState {
pub hover: ActivationState,
}
#[derive(Clone)]
pub struct CursorData<'a> {
/// where this widget was hit
pub pos: Vec2,
pub size: Vec2,
pub scroll_delta: Vec2,
pub hover: ActivationState,
pub cursor: CursorState,
/// the first sense that triggered this
pub sense: CursorSense,
pub render: &'a UiRenderState,
}
pub trait SensorUi {
fn run_sensors<Rsc: HasEvents>(
&self,
rsc: &mut Rsc,
state: &mut Rsc::State,
cursor: CursorState,
window_size: Vec2,
);
}
impl SensorUi for UiRenderState {
fn run_sensors<Rsc: HasEvents>(
&self,
rsc: &mut Rsc,
state: &mut Rsc::State,
cursor: CursorState,
window_size: Vec2,
) {
// in order to remove this take, need to store active list in UiRenderState somehow
// this would probably be done through a generic parameter that adds yet another rsc /
// state like thing, but local to render state, and is passed to UiRsc events so you can
// update it there?
let mut active = std::mem::take(&mut rsc.events_mut().get_type::<CursorSense>().active);
for layer in self.layers.indices().rev() {
let mut sensed = false;
for (id, sensor) in active.get_mut(&layer).into_flat_iter() {
let shape = self.active.get(id).unwrap().region;
let region = shape.to_px(window_size);
let in_shape = cursor.exists && region.contains(cursor.pos);
sensor.hover.update(in_shape);
if sensor.hover == ActivationState::Off {
continue;
}
sensed = true;
let cursor = cursor.clone();
let data = CursorData {
pos: cursor.pos - region.top_left,
size: region.bot_right - region.top_left,
scroll_delta: cursor.scroll_delta,
hover: sensor.hover,
cursor,
// this does not have any meaning;
// might wanna set up Event to have a prepare stage
sense: CursorSense::Hovering,
render: self,
};
rsc.run_event::<CursorSense>(*id, data, state);
}
if sensed {
break;
}
}
rsc.events_mut().get_type::<CursorSense>().active = active;
}
}
pub fn should_run(
senses: &CursorSenses,
cursor: &CursorState,
hover: ActivationState,
) -> Option<CursorSense> {
for sense in senses.iter() {
if match sense {
CursorSense::PressStart(button) => cursor.buttons.select(button).is_start(),
CursorSense::Pressing(button) => cursor.buttons.select(button).is_on(),
CursorSense::PressEnd(button) => cursor.buttons.select(button).is_end(),
CursorSense::HoverStart => hover.is_start(),
CursorSense::Hovering => hover.is_on(),
CursorSense::HoverEnd => hover.is_end(),
CursorSense::Scroll => cursor.scroll_delta != Vec2::ZERO,
} {
return Some(*sense);
}
}
None
}
impl ActivationState {
pub fn is_start(&self) -> bool {
*self == Self::Start
}
pub fn is_on(&self) -> bool {
*self == Self::Start || *self == Self::On
}
pub fn is_end(&self) -> bool {
*self == Self::End
}
pub fn is_off(&self) -> bool {
*self == Self::End || *self == Self::Off
}
pub fn update(&mut self, on: bool) {
*self = match *self {
Self::Start => match on {
true => Self::On,
false => Self::End,
},
Self::On => match on {
true => Self::On,
false => Self::End,
},
Self::End => match on {
true => Self::Start,
false => Self::Off,
},
Self::Off => match on {
true => Self::Start,
false => Self::Off,
},
}
}
pub fn end_frame(&mut self) {
match self {
Self::Start => *self = Self::On,
Self::End => *self = Self::Off,
_ => (),
}
}
}
impl EventLike for CursorSense {
type Event = CursorSenses;
fn into_event(self) -> Self::Event {
self.into()
}
}
impl Deref for CursorSenses {
type Target = Vec<CursorSense>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for CursorSenses {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl From<CursorSense> for CursorSenses {
fn from(val: CursorSense) -> Self {
CursorSenses(vec![val])
}
}
impl BitOr for CursorSense {
type Output = CursorSenses;
fn bitor(self, rhs: Self) -> Self::Output {
CursorSenses(vec![self, rhs])
}
}
impl BitOr<CursorSense> for CursorSenses {
type Output = Self;
fn bitor(mut self, rhs: CursorSense) -> Self::Output {
self.0.push(rhs);
self
}
}
+83
View File
@@ -0,0 +1,83 @@
//! The trace toggle for the `iris::input`/`iris::frame` diagnostics (Iris's
//! 2026-09-07 request: "add another button to copy input event info ...
//! instrument a lot of the code with timings"), and the one place both
//! call sites' `iris::frame` line is written from.
//!
//! **Why a crate-level flag instead of `log::log_enabled!`/
//! `log::set_max_level`**: the app already installs its logger at
//! `LevelFilter::Debug` (`iris/android-app/src/lib.rs`'s `JNI_OnLoad`), so
//! a `log::Level::Debug` line reaches `client_core::log_ring`'s ring
//! regardless of what this instrument would prefer -- `RingLogger::enabled`
//! is unconditionally `true` by design (its own doc: "the ring wants
//! everything"). So the level alone cannot give these two targets a
//! default-off switch; the gate has to live on this side, checked before
//! `log::debug!` is even reached.
//!
//! **Why default off matters**: the ring is 2000 lines / 256 KiB
//! (`client_core::log_ring::DEFAULT_MAX_LINES`/`DEFAULT_MAX_BYTES`), and a
//! 120Hz session logging both a line per touch sample and a line per frame
//! fills that in seconds -- so a caller turns this on only for the length
//! of whatever is being investigated, and the report says so at its top
//! (a caller's job; see `iris::diagnostics::trace_enabled` used at the top
//! of whatever builds the report).
//!
//! **Not yet wired to a control**: the Diagnostics pane that would hold the
//! switch is in `iris/android-app/src/bench_client.rs`, which another agent
//! has open at the same time this was written. `set_trace` is the whole
//! surface a button needs; wiring one is a follow-up.
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use iris_core::UiRenderState;
static TRACE: AtomicBool = AtomicBool::new(false);
/// Turns the `iris::input`/`iris::frame` `debug!` lines on or off. Off by
/// default -- see the module doc for why turning the level on alone would
/// not do it.
pub fn set_trace(on: bool) {
TRACE.store(on, Ordering::Relaxed);
}
/// Whether the `iris::input`/`iris::frame` lines are enabled right now --
/// what a report's header reads before deciding what to say about the
/// lines it does or doesn't hold (UI_RULES.md: "design the unknown state
/// first").
pub fn trace_enabled() -> bool {
TRACE.load(Ordering::Relaxed)
}
/// One `iris::frame` line, called once per frame from each backend's own
/// frame function -- `android::view::IrisViewPeer::render`,
/// `default::DefaultApp::window_event`'s `RedrawRequested` arm, and
/// `harness::Harness::frame` -- after the draw (or, on the harness, where a
/// draw would be; `draw` is `Duration::ZERO` there since nothing is
/// actually submitted to a GPU).
///
/// `render.update(...)` must already have run this frame: this reads back
/// what it recorded (`UiRenderState::last_layout_duration`/
/// `last_redraw_kind`/`frame_number`) rather than timing anything itself,
/// so a caller's own measurement of the phase around `update()` and around
/// its own draw call are the only two `Instant` pairs in the whole path --
/// see each call site's own comment for why it is not restructured to fit
/// this instead.
pub fn log_frame(render: &UiRenderState, now: Instant, draw: Duration, animating: bool) {
if !trace_enabled() {
return;
}
let since_input = render
.time_since_input(now)
.map(|d| format!("{}ms", d.as_millis()))
.unwrap_or_else(|| "none".to_string());
log::debug!(
target: "iris::frame",
"iris frame: n={} now={}ms since_input={since_input} layout={:?} draw={:?} \
redraw={:?} primitives={} animating={animating}",
render.frame_number(),
now.duration_since(render.epoch()).as_millis(),
render.last_layout_duration(),
draw,
render.last_redraw_kind(),
render.active_primitive_count(),
);
}
+24 -4
View File
@@ -2,7 +2,21 @@ use iris_core::*;
use iris_macro::*; use iris_macro::*;
use std::sync::Arc; use std::sync::Arc;
use crate::default::{TaskCtx, TaskUpdate, Tasks}; use crate::task::{TaskCtx, TaskUpdate, Tasks};
/// A field's Enter key (without a shift, in a multi-line field). Backend
/// input handling raises it directly rather than through `on`, since a
/// field does not know ahead of time whether anything is listening.
#[derive(Eq, PartialEq, Hash, Clone)]
pub struct Submit;
impl Event for Submit {}
/// A field's content changed as a result of input the backend applied
/// directly to it (a keystroke, an IME commit) rather than through a
/// widget event handler.
#[derive(Eq, PartialEq, Hash, Clone)]
pub struct Edited;
impl Event for Edited {}
pub trait Eventable<Rsc: HasEvents, Tag>: WidgetLike<Rsc, Tag> { pub trait Eventable<Rsc: HasEvents, Tag>: WidgetLike<Rsc, Tag> {
fn on<E: EventLike>( fn on<E: EventLike>(
@@ -30,13 +44,19 @@ impl<WL: WidgetLike<Rsc, Tag>, Rsc: HasEvents, Tag> Eventable<Rsc, Tag> for WL {
widget_trait! { widget_trait! {
pub trait TaskEventable<Rsc: HasEvents + HasTasks>; pub trait TaskEventable<Rsc: HasEvents + HasTasks>;
fn task_on<'a, E: EventLike, F: AsyncWidgetEventFn<Rsc, WL::Widget>>( /// No `Data: Send` bound, deliberately: the registered handler below
/// takes `|_, rsc|` and the event's data never crosses into the
/// spawned future -- `AsyncEventIdCtx` carries the widget id and the
/// task handle and nothing else. The bound used to be here anyway, and
/// it was the whole reason `CursorData`'s pointer state was behind a
/// `Mutex` rather than owned by the input handler (Iris, 2026-09-08:
/// never reach for a lock first).
fn task_on<E: EventLike, F: AsyncWidgetEventFn<Rsc, WL::Widget>>(
self, self,
event: E, event: E,
f: F, f: F,
) -> impl WidgetIdFn<Rsc, WL::Widget> ) -> impl WidgetIdFn<Rsc, WL::Widget>
where <E::Event as Event>::Data<'a>: Send, where for<'b> F::CallRefFuture<'b>: Send,
for<'b> F::CallRefFuture<'b>: Send,
{ {
let f = Arc::new(f); let f = Arc::new(f);
move |rsc| { move |rsc| {
+428
View File
@@ -0,0 +1,428 @@
//! Layer 1 of docs/RUST.md's "Three test layers": a whole screen driven
//! in-process with **no window, no compositor and no GPU**, on an
//! explicit clock and a replayed touch stream.
//!
//! `layout_tests.rs` and `sense_tests.rs` already build trees over
//! `UiRenderState` with a hand-rolled `Rsc` each; this is the same idea
//! carried far enough to open a real app screen (`transcript-ui`'s, over
//! the bench fixture -- see the `transcript-fixture` crate) at the
//! phone's size and density, feed it a recorded flick, and assert on
//! where the list ended up. What it answers that the emulator cannot:
//! Android batches a 120Hz flick into one or two `MotionEvent`s
//! (`CursorState::time`), and a `ui-trace` swipe is many evenly-spaced
//! ones -- so the gesture shape a finger actually makes is only
//! reproducible from a *file* of timestamped samples.
//!
//! It is a third backend in the sense `default/` and `android/` are, and
//! deliberately the smallest one: the platform half of each of those
//! (a surface, an IME, a URL opener) becomes a recorded fact here --
//! [`HarnessState::keyboard_shown`], [`HarnessState::opened_urls`] --
//! so a test can assert the platform *was asked*, which is the only
//! thing either backend does with those calls anyway.
//!
//! ```ignore
//! let mut h = Harness::new(phone_size(), PHONE_SCALE);
//! let screen = transcript_ui::build(&mut h.rsc, &mut h.state, rows);
//! h.frame(0);
//! h.replay(&TouchScript::parse(include_str!("flick.touch"))?);
//! h.frames_until(20, 2_000, 8);
//! ```
use crate::prelude::*;
use std::marker::PhantomData;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
/// One replayed pointer sample: what Android's `MotionEvent` carries, cut
/// down to the part iris reads (`IrisViewPeer::on_touch_event`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TouchAction {
Down,
Move,
Up,
/// The gesture taken away by the system (a parent view claiming it, a
/// call arriving, the swipe up from the bottom edge to leave the
/// app). It ends the press, because a release that never arrives
/// leaves pointer capture held forever -- but it is not a release,
/// and nothing follows from it: no tap, no selection, no fling. See
/// `CursorState::cancelled`, which is what it sets.
Cancel,
}
impl TouchAction {
fn parse(word: &str) -> Option<Self> {
match word {
"down" => Some(Self::Down),
"move" => Some(Self::Move),
"up" => Some(Self::Up),
"cancel" => Some(Self::Cancel),
_ => None,
}
}
/// The inverse of [`Self::parse`] -- what [`Harness::touch`] hands
/// [`crate::sense::log_input_event`], so an `iris::input` line and a
/// `.touch` file agree on one spelling of each action.
pub fn word(self) -> &'static str {
match self {
Self::Down => "down",
Self::Move => "move",
Self::Up => "up",
Self::Cancel => "cancel",
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct TouchSample {
/// Milliseconds since the start of the recording -- the sample's own
/// time, which becomes `CursorState::time`. See that field's doc for
/// why a replay may not date its samples by when the loop got to
/// them.
pub t_ms: u64,
pub action: TouchAction,
pub pos: Vec2,
}
/// A recorded gesture: one `t_ms action x y` line per sample, `#` and
/// blank lines ignored. Deliberately a plain text file rather than a
/// serialisation format -- it is written by hand as often as it is
/// recorded, and a diff of one has to be readable.
pub struct TouchScript {
pub samples: Vec<TouchSample>,
}
impl TouchScript {
/// Parses a script, naming the line and what was wrong with it: these
/// are hand-written files, so a typo is the ordinary case and
/// "expected 4 fields" without a line number is not enough to fix it.
pub fn parse(text: &str) -> Result<Self, String> {
let mut samples: Vec<TouchSample> = Vec::new();
for (i, line) in text.lines().enumerate() {
let line = line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let at = |what: &str| format!("touch script line {}: {what}: {line:?}", i + 1);
let mut words = line.split_whitespace();
let (Some(t), Some(action), Some(x), Some(y), None) = (
words.next(),
words.next(),
words.next(),
words.next(),
words.next(),
) else {
return Err(at("expected `t_ms action x y`"));
};
let t_ms: u64 = t.parse().map_err(|_| at("t_ms is not a whole number"))?;
let action = TouchAction::parse(action)
.ok_or_else(|| at("action is not down/move/up/cancel"))?;
let x: f32 = x.parse().map_err(|_| at("x is not a number"))?;
let y: f32 = y.parse().map_err(|_| at("y is not a number"))?;
if let Some(last) = samples.last()
&& t_ms < last.t_ms
{
return Err(at("samples must be in time order"));
}
samples.push(TouchSample {
t_ms,
action,
pos: Vec2::new(x, y),
});
}
Ok(Self { samples })
}
/// The last sample's time, i.e. how long the recording runs.
pub fn end_ms(&self) -> u64 {
self.samples.last().map(|s| s.t_ms).unwrap_or(0)
}
}
/// Counts the frames something asked for without drawing any -- the
/// harness's `RequestRedraw`. A `LazySpan` coasting through a fling asks for
/// the next frame through this (`UiData::animate` and `Widget::tick`), so a test can
/// tell "nothing moved" from "nothing was even asked to move".
#[derive(Default)]
pub struct RedrawCounter(AtomicUsize);
impl RedrawCounter {
pub fn count(&self) -> usize {
self.0.load(Ordering::Relaxed)
}
}
impl RequestRedraw for RedrawCounter {
fn request_redraw(&self) {
self.0.fetch_add(1, Ordering::Relaxed);
}
}
/// The harness's app state: what each real backend keeps for the platform
/// half, recorded instead of performed.
pub struct HarnessState {
pub root: Option<StrongWidget>,
pub focus: Option<WeakWidget<TextEdit>>,
last_click: Instant,
/// How many times a tap asked for the keyboard (`FocusHost::
/// focus_gained` with a region -- `showSoftInput` on Android,
/// `set_ime_cursor_area` on winit). The platform's own answer is not
/// available here, so this says what was *asked*, and a test must not
/// read it as "the IME is up".
pub keyboard_shown: usize,
/// Every URL a tapped link asked the platform to open, in order.
pub opened_urls: Vec<String>,
}
impl HarnessState {
fn new() -> Self {
Self {
root: None,
focus: None,
last_click: Instant::now(),
keyboard_shown: 0,
opened_urls: Vec::new(),
}
}
}
impl HasRoot for HarnessState {
fn set_root(&mut self, root: StrongWidget) {
self.root = Some(root);
}
}
impl FocusHost for HarnessState {
fn recent_click(&mut self) -> bool {
crate::attr::recent_click(&mut self.last_click)
}
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
self.focus = id;
}
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
self.focus == Some(id)
}
fn focus_gained(&mut self, region: Option<PixelRegion>) {
if region.is_some() {
self.keyboard_shown += 1;
}
}
}
impl OpenUrl for HarnessState {
fn open_url(&mut self, url: &str) {
self.opened_urls.push(url.to_string());
}
}
/// The harness's `Rsc` -- identical in substance to `DefaultRsc`/
/// `AndroidRsc` minus the windowing, for the same reason those two are
/// separate types (`AndroidRsc`'s own doc).
pub struct HarnessRsc {
pub ui: UiData,
pub events: EventManager<Self>,
pub tasks: Tasks<Self>,
pub state: WidgetState,
_state: PhantomData<HarnessState>,
}
impl UiRsc for HarnessRsc {
fn ui(&self) -> &UiData {
&self.ui
}
fn ui_mut(&mut self) -> &mut UiData {
&mut self.ui
}
fn on_draw(&mut self, active: &ActiveData) {
self.events.draw(active);
}
fn on_undraw(&mut self, active: &ActiveData) {
self.events.undraw(active);
}
fn on_remove(&mut self, id: WidgetId) {
self.events.remove(id);
self.state.remove(id);
}
}
impl HasState for HarnessRsc {
type State = HarnessState;
}
impl HasEvents for HarnessRsc {
fn events(&self) -> &EventManager<Self> {
&self.events
}
fn events_mut(&mut self) -> &mut EventManager<Self> {
&mut self.events
}
}
impl HasTasks for HarnessRsc {
fn tasks_mut(&mut self) -> &mut Tasks<Self> {
&mut self.tasks
}
}
impl HasWidgetState for HarnessRsc {
fn widget_state(&self) -> &WidgetState {
&self.state
}
fn widget_state_mut(&mut self) -> &mut WidgetState {
&mut self.state
}
}
impl<I: RscIdx<HarnessRsc>> std::ops::Index<I> for HarnessRsc {
type Output = I::Output;
fn index(&self, index: I) -> &Self::Output {
index.get(self)
}
}
impl<I: RscIdx<HarnessRsc>> std::ops::IndexMut<I> for HarnessRsc {
fn index_mut(&mut self, index: I) -> &mut Self::Output {
index.get_mut(self)
}
}
/// A screen running with no window: the widget tree, the frame loop and
/// the pointer, all advanced by the caller. See the module doc.
pub struct Harness {
pub rsc: HarnessRsc,
pub render: UiRenderState,
pub state: HarnessState,
task_recv: TaskMsgReceiver<HarnessRsc>,
redraws: Arc<RedrawCounter>,
cursor: CursorState,
/// Time zero. Every `t_ms` in this harness is an offset from here, so
/// nothing reads the wall clock -- see [`Self::at`].
base: Instant,
size: Vec2,
}
impl Harness {
/// `size` is in physical pixels and `density` is physical pixels per
/// dp, the pair Android reads from the surface and
/// `DisplayMetrics.density` (`AndroidUiState::content_scale`). The
/// phone's own numbers are `transcript_fixture::PHONE_SIZE`/
/// `PHONE_SCALE`.
pub fn new(size: Vec2, density: f32) -> Self {
let redraws = Arc::new(RedrawCounter::default());
let (tasks, task_recv) = Tasks::init(redraws.clone());
let mut rsc = HarnessRsc {
ui: UiData::default(),
events: EventManager::default(),
tasks,
state: WidgetState::default(),
_state: PhantomData,
};
rsc.ui.text.density = density;
let mut render = UiRenderState::new();
render.set_density(density);
render.resize(size);
Self {
rsc,
render,
state: HarnessState::new(),
task_recv,
redraws,
cursor: CursorState::default(),
base: Instant::now(),
size,
}
}
/// The `Instant` this harness means by `t_ms`. Public because a
/// caller driving `ScrollController::tick` or `DragGesture` by hand needs
/// to date those calls on the same clock the touch samples use.
pub fn at(&self, t_ms: u64) -> Instant {
self.base + Duration::from_millis(t_ms)
}
pub fn size(&self) -> Vec2 {
self.size
}
/// How many frames were asked for so far -- see [`RedrawCounter`].
pub fn redraws(&self) -> usize {
self.redraws.count()
}
/// One frame at `t_ms`: drain finished tasks, advance anything
/// animating, lay out and "draw". The same three steps
/// `DefaultApp::window_event`'s `RedrawRequested` arm and
/// `IrisViewPeer::render` take, minus handing primitives to a GPU.
pub fn frame(&mut self, t_ms: u64) {
while let Ok(update) = self.task_recv.try_recv() {
update(&mut self.state, &mut self.rsc);
}
let now = self.at(t_ms);
let animating = self.rsc.ui.tick_animations(now);
self.render.update(&self.state.root, &mut self.rsc);
// No GPU here, so there is no draw phase to time -- `draw` is
// always zero. `layout`/`redraw`/`primitives` are still real,
// because `render.update` just ran; see
// `iris::diagnostics::log_frame`'s own doc for why this reads
// those back rather than timing anything itself.
crate::diagnostics::log_frame(&self.render, now, Duration::ZERO, animating);
}
/// Frames every `step_ms` up to and including `end_ms` -- what a
/// fling needs, since it moves only while something ticks it
/// (`ScrollController::fling`'s doc). Returns the time of the last frame run.
pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 {
debug_assert!(step_ms > 0, "a frame loop with no step never ends");
let mut t = from_ms;
while t <= end_ms {
self.frame(t);
t += step_ms;
}
t - step_ms
}
/// One pointer sample through the sensors, then the frame it belongs
/// to -- `IrisViewPeer::on_touch_event` and `after_input`, in one
/// call. Each sample is its own input frame, dated by the sample
/// rather than by when this ran.
pub fn touch(&mut self, action: TouchAction, pos: Vec2, t_ms: u64) {
self.cursor.time = self.at(t_ms);
self.cursor.pos = pos;
match action {
TouchAction::Down => {
self.cursor.exists = true;
self.cursor.buttons.left.update(true);
}
TouchAction::Move => {}
TouchAction::Up => self.cursor.buttons.left.update(false),
// The platform taking the gesture away, not the finger
// lifting -- see `CursorState::cancelled`.
TouchAction::Cancel => {
self.cursor.buttons.left.update(false);
self.cursor.cancelled = true;
}
}
// Layer 1's half of `iris::input` (`sense::log_input_event`'s own
// doc): no batching happens here, so `historical` is always empty
// and `t_ms` is the script's own column, which is what makes this
// round-trip through `report_to_touch.py` back into an identical
// `TouchScript`.
crate::sense::log_input_event(action.word(), pos.x, pos.y, t_ms, &[]);
let cursor = self.cursor.clone();
self.render
.run_sensors(&mut self.rsc, &mut self.state, cursor, self.size);
self.frame(t_ms);
self.cursor.end_frame();
}
/// Replays a whole recorded gesture. Nothing is inserted between the
/// samples: a file with three lines produces three input frames, so
/// the batched shape a real flick arrives in is preserved exactly as
/// recorded rather than smoothed into evenly-spaced motion.
pub fn replay(&mut self, script: &TouchScript) {
for sample in &script.samples {
self.touch(sample.action, sample.pos, sample.t_ms);
}
}
}
+1079
View File
File diff suppressed because it is too large. Load diff
+37 -2
View File
@@ -1,24 +1,59 @@
#![feature(unboxed_closures)] #![feature(unboxed_closures)]
#![feature(fn_traits)] #![feature(fn_traits)]
#![feature(gen_blocks)] // Only `default::DefaultAppState::Event`'s default uses this; unused (and
#![feature(associated_type_defaults)] // warned about) on the android target, which has no such default.
#![cfg_attr(not(target_os = "android"), feature(associated_type_defaults))]
#![feature(unsize)] #![feature(unsize)]
#![feature(option_into_flat_iter)] #![feature(option_into_flat_iter)]
#![feature(async_fn_traits)] #![feature(async_fn_traits)]
// Two windowing backends live side by side, chosen by target rather than by
// feature flag: winit everywhere but Android, android-view on it. They are
// mutually exclusive rather than both-compiled-in because winit's own
// Android support pulls in `android-activity`, which needs one of its
// `game-activity`/`native-activity` features selected -- exactly what
// `iris-core` was kept free of, and android-view is the framework's own
// answer to the same surface on that platform. See RUST.md's I2.
#[cfg(target_os = "android")]
pub mod android;
#[cfg(not(target_os = "android"))]
pub mod default; pub mod default;
pub mod attr;
pub mod diagnostics;
pub mod event; pub mod event;
pub mod harness;
pub mod platform;
pub mod sense;
pub mod state;
pub mod task;
pub mod widget; pub mod widget;
#[cfg(test)]
mod access_tests;
#[cfg(test)]
mod layout_tests;
#[cfg(test)]
mod sense_tests;
pub use iris_core as core; pub use iris_core as core;
pub use iris_macro as macros; pub use iris_macro as macros;
pub mod prelude { pub mod prelude {
use super::*; use super::*;
#[cfg(target_os = "android")]
pub use android::*;
#[cfg(not(target_os = "android"))]
pub use default::*; pub use default::*;
pub use attr::*;
pub use event::*; pub use event::*;
pub use iris_core::*; pub use iris_core::*;
pub use iris_macro::*; pub use iris_macro::*;
pub use platform::*;
pub use sense::*;
pub use state::*;
pub use task::*;
pub use widget::*; pub use widget::*;
pub use iris_core::util::Vec2; pub use iris_core::util::Vec2;
+22
View File
@@ -0,0 +1,22 @@
//! Capabilities a widget tree needs from whatever is hosting it, that
//! neither iris nor the app can perform itself.
//!
//! Same shape as [`crate::attr::FocusHost`], and for the same reason: the
//! interface is declared here, below, and implemented by each backend
//! above (`default/platform.rs`, `android/platform.rs`), so a widget can
//! ask for the capability by trait bound instead of a caller threading a
//! callback down through every builder.
/// Hand a URL to whatever the platform opens URLs with.
///
/// One method rather than a general "run an intent"/"exec" surface: the
/// only thing a transcript needs is to follow a link a reader tapped, and
/// a narrower capability is a narrower thing to get wrong.
///
/// **Nothing is reported back.** There is no answer worth branching on --
/// the platform either shows a browser or does not, and both are outside
/// this process -- so failures are logged where they happen (each impl)
/// rather than turned into a `Result` every call site would discard.
pub trait OpenUrl {
fn open_url(&mut self, url: &str);
}
+3171
View File
File diff suppressed because it is too large. Load diff
+781
View File
@@ -0,0 +1,781 @@
//! IRIS_TODO.md's "Input does not fall through by input type": a widget
//! that only registered `click()` used to also block a `ScrollArea` meant for
//! whatever is behind it, because `run_sensors` decided "consumed, stop
//! looking at lower layers" from mere hover, not from anything actually
//! matching. Exercised as a plain unit test for the same reason
//! `layout_tests.rs` is one: `UiRenderState` and a minimal `HasEvents`
//! impl need no GPU or window.
use crate::prelude::*;
use std::{cell::Cell, rc::Rc, time::Instant};
struct SenseRsc {
ui: UiData,
events: EventManager<SenseRsc>,
}
impl UiRsc for SenseRsc {
fn ui(&self) -> &UiData {
&self.ui
}
fn ui_mut(&mut self) -> &mut UiData {
&mut self.ui
}
fn on_draw(&mut self, active: &ActiveData) {
self.events.draw(active);
}
fn on_undraw(&mut self, active: &ActiveData) {
self.events.undraw(active);
}
fn on_remove(&mut self, id: WidgetId) {
self.events.remove(id);
}
}
impl HasState for SenseRsc {
type State = ();
}
impl HasEvents for SenseRsc {
fn events(&self) -> &EventManager<Self> {
&self.events
}
fn events_mut(&mut self) -> &mut EventManager<Self> {
&mut self.events
}
}
fn cursor_at(pos: Vec2) -> CursorState {
CursorState {
pos,
exists: true,
buttons: Default::default(),
scroll_delta: Vec2::ZERO,
..Default::default()
}
}
#[test]
fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// Both cover the whole window -- the button "sitting over" the list,
// the case in IRIS_TODO.md's report.
let list = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let list_weak = list.weak();
let button = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED));
let button_weak = button.weak();
let scrolled = Rc::new(Cell::new(false));
let clicked = Rc::new(Cell::new(false));
{
let scrolled = scrolled.clone();
rsc.register_event(list_weak, CursorSense::Scroll, move |_ctx, _rsc| {
scrolled.set(true);
});
}
{
let clicked = clicked.clone();
rsc.register_event(button_weak, CursorSense::click(), move |_ctx, _rsc| {
clicked.set(true);
});
}
// A Stack draws its children on separate layers in order, which is
// exactly the "one thing drawn over another" shape `run_sensors`
// walks top layer first.
let root = rsc
.ui
.widgets
.add_strong(Stack {
children: vec![list.any(), button.any()],
size: StackSize::default(),
})
.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
let mut state = ();
let mut scroll_cursor = cursor_at((50.0, 50.0).into());
scroll_cursor.scroll_delta = (0.0, 10.0).into();
render.run_sensors(&mut rsc, &mut state, scroll_cursor, (100.0, 100.0).into());
render.update(&root, &mut rsc);
assert!(
scrolled.get(),
"a scroll over the button must still reach the list underneath it"
);
assert!(
!clicked.get(),
"a scroll is not a click; the button must not have fired"
);
let mut click_cursor = cursor_at((50.0, 50.0).into());
click_cursor.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, click_cursor, (100.0, 100.0).into());
render.update(&root, &mut rsc);
assert!(
clicked.get(),
"the button on top must still receive an actual click"
);
}
/// The bug behind "finger flings do nothing" (RUST.md's P0 phone report,
/// defect 2): a fast gesture's `PressEnd` can land at a screen position
/// nothing is registered at -- past the edge of whatever widget noticed
/// the press, in a gap, or off the loaded content entirely. Before pointer
/// capture, `run_sensors`' hit test simply delivered nothing that frame,
/// so a widget mid-drag never saw its release and never got a chance to
/// start a fling. `PointerRequests::capture`/`DragGesture` fix this
/// by giving the drag's widget every frame regardless of where the
/// pointer is, including the terminal `Drop` in place of `PressEnd`.
#[test]
fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// A small draggable widget in the corner -- the release below lands
// far outside it, exactly the "moved off the hit region" case.
let draggable = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
let draggable_weak = draggable.weak();
let dropped = Rc::new(Cell::new(false));
{
let dropped = dropped.clone();
rsc.register_event(
draggable_weak,
CursorSense::click_or_drag() | CursorSense::unclick() | CursorSense::Drop,
move |ctx, rsc| match ctx.data.sense {
CursorSense::PressStart(_) | CursorSense::Pressing(_) => {
// Any committed drag takes capture -- a real caller
// would gate this on a `DragArbiter`/`DragGesture`
// decision, but this test only needs to exercise the
// capture-and-release mechanics themselves.
ctx.data.pointer.capture(draggable_weak.id());
let _ = rsc;
}
CursorSense::Drop => dropped.set(true),
_ => {}
},
);
}
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&draggable, &mut rsc);
let mut state = ();
let mut press = cursor_at((5.0, 5.0).into());
press.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, press, (100.0, 100.0).into());
render.update(&draggable, &mut rsc);
assert_eq!(
pointer_input(&mut rsc).holder(),
Some(draggable.id()),
"the press should have taken capture"
);
// The release lands nowhere near the widget's own region -- the exact
// shape of a fast fling's `ACTION_UP`.
let mut release = cursor_at((95.0, 95.0).into());
release.buttons.left = ActivationState::End;
render.run_sensors(&mut rsc, &mut state, release, (100.0, 100.0).into());
render.update(&draggable, &mut rsc);
assert!(
dropped.get(),
"a release outside every widget's hit region must still reach \
the widget holding pointer capture"
);
assert_eq!(
pointer_input(&mut rsc).holder(),
None,
"Drop must release the capture"
);
}
/// A widget that never registers `CursorSense::Drop` at all must not be
/// affected by someone else's capture -- capture is per-gesture, not
/// global suppression of the whole input system for widgets that were
/// never party to it. (Practically this matters because a captured
/// widget's registration list still has to include `Drop` for `should_run`
/// to ever match it; this pins that half of the contract.)
#[test]
fn capturing_one_widget_starves_every_other_widget_of_events() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let a = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let a_weak = a.weak();
let b = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED));
let b_weak = b.weak();
let b_hovered = Rc::new(Cell::new(false));
{
let b_hovered = b_hovered.clone();
rsc.register_event(b_weak, CursorSense::Hovering, move |_ctx, _rsc| {
b_hovered.set(true);
});
}
let root = rsc
.ui
.widgets
.add_strong(Stack {
children: vec![a.any(), b.any()],
size: StackSize::default(),
})
.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
pointer_input(&mut rsc).set_holder(Some(a_weak.id()));
let mut state = ();
let cursor = cursor_at((50.0, 50.0).into());
render.run_sensors(&mut rsc, &mut state, cursor, (100.0, 100.0).into());
render.update(&root, &mut rsc);
assert!(
!b_hovered.get(),
"while a's drag holds capture, b must see no hover at all"
);
}
/// IRIS_TODO.md's "the composer has no touch-drag scroll": `ScrollArea` only
/// answered a wheel, so a finger drag over overflowed text did nothing.
/// End-to-end over the real wiring -- `scrollable()`'s own registration,
/// `run_sensors`' dispatch, `ScrollController::drag`, `DragGesture`'s arbitration and
/// pointer capture -- rather than only `ScrollController::drag`'s own unit tests in
/// `scroll.rs`, because the registration is exactly the half those cannot
/// see.
#[test]
fn a_finger_drag_over_a_scroll_area_pans_it() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// 1000px of content in a 100px window: room to pan.
let scroll_strong = rect(UiColor::WHITE)
.height(Len::abs(1000.0))
.scrollable(Axis::Y, Pin::Start)
.add_strong(&mut rsc);
let scroll = scroll_strong.weak();
let root = scroll_strong.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
// `ScrollArea` reads its content length back from the draw it just did, so
// the frame after is the first one that knows there is anything to pan
// -- the one-frame lag LAYOUT.md section 4 documents. `scroll(0.0)` is
// how `layout_tests.rs` asks for that second frame, and it also drops
// `snap_end`, leaving this parked at the start of the content.
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
assert_eq!(rsc.ui.widgets.get(&scroll).unwrap().amt(), 0.0);
let mut state = ();
let mut down = cursor_at((50.0, 80.0).into());
down.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, down, (100.0, 100.0).into());
render.update(&root, &mut rsc);
assert_eq!(
rsc.ui.widgets.get(&scroll).unwrap().amt(),
0.0,
"the touch-down alone must not move anything"
);
// Inside the slop: still a tap as far as anything can tell.
let mut nudge = cursor_at((50.0, 80.0 - (DRAG_SLOP - 1.0)).into());
nudge.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, nudge, (100.0, 100.0).into());
render.update(&root, &mut rsc);
assert_eq!(
rsc.ui.widgets.get(&scroll).unwrap().amt(),
0.0,
"a press inside DRAG_SLOP must not scroll"
);
// Past it, upward: the content follows the finger up, which for this
// widget means more `amt`.
let mut drag = cursor_at((50.0, 80.0 - (DRAG_SLOP + 40.0)).into());
drag.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, drag, (100.0, 100.0).into());
render.update(&root, &mut rsc);
let after = rsc.ui.widgets.get(&scroll).unwrap().amt();
assert!(
(after - 40.0).abs() < 0.01,
"expected the 40px past the slop to pan it, got {after}"
);
// And the gesture holds the pointer, so the rest of it reaches this
// widget even once the finger leaves its box.
assert_eq!(pointer_input(&mut rsc).holder(), Some(scroll.id()));
}
/// docs/REVIEW-2026-09-07.md's D4. The first `MotionEvent` a view sees can
/// be a `Move` -- the `Down` went to another view, or the view was attached
/// mid-gesture -- and its batched samples are older than its own
/// timestamp. Anchoring on that timestamp clamped every one of them onto
/// the anchor, so the tracker saw three samples at one instant, the Lsq2
/// fit went degenerate, and the flick read 0 px/s.
#[test]
fn the_first_events_batched_samples_are_dated_apart() {
const MS: i64 = 1_000_000;
let now = Instant::now();
// A 120Hz batch: three historical samples at 0/4/8ms and the event's
// own at 12ms.
let clock = PointerClock::anchored(now, 12 * MS, 0);
assert_eq!(
clock.at(12 * MS),
now,
"the event's own sample is the one that arrived now"
);
let batch = [clock.at(0), clock.at(4 * MS), clock.at(8 * MS)];
assert!(
batch[0] < batch[1] && batch[1] < batch[2] && batch[2] < now,
"the batch must keep the 4ms between its samples, got {:?}",
batch
.iter()
.map(|t| now.duration_since(*t))
.collect::<Vec<_>>()
);
assert_eq!(clock.ms_since_anchor(8 * MS), 8);
}
/// The same clock has to keep ordering *across* events: the sample it
/// compares a new event's first sample against is the previous event's
/// last one, never the anchor.
#[test]
fn the_clock_orders_samples_across_events() {
const MS: i64 = 1_000_000;
let mut clock = PointerClock::anchored(Instant::now(), 12 * MS, 0);
let first = clock.sample(12 * MS);
let second = clock.sample(28 * MS);
assert!(second > first);
assert_eq!(
second.duration_since(first),
std::time::Duration::from_millis(16)
);
}
/// Iris's 2026-09-08 phone report, first half: "it keeps snapping back to
/// some position when horizontally scrolling."
///
/// A `ScrollArea` that has committed to a pan holds the pointer, so the
/// gesture's end arrives as `CursorSense::Drop` -- and `scrollable`
/// used to register `click_or_drag | unclick` only, which `should_run`
/// never matches a `Drop` against. So the widget never learned its own
/// gesture had ended: its `DragArbiter` stayed `Panning` at the position
/// the finger left, and the *next* drag's first frame was measured from
/// there and applied in one step. The registration is
/// `CursorSense::drag_senses()` now, which is the rule for every widget
/// driving a `DragGesture` rather than a fact about this one.
#[test]
fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
let scroll_strong = rect(UiColor::WHITE)
.height(Len::abs(1000.0))
.scrollable(Axis::Y, Pin::Start)
.add_strong(&mut rsc);
let scroll = scroll_strong.weak();
let root = scroll_strong.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
let mut state = ();
let win = Vec2::new(100.0, 100.0);
let mut send = |render: &mut UiRenderState, rsc: &mut SenseRsc, y: f32, button| {
let mut c = cursor_at((50.0, y).into());
c.buttons.left = button;
render.run_sensors(rsc, &mut state, c, win);
render.update(&root, rsc);
};
// One pan of 40px past the slop, then a release well outside the
// widget -- the ordinary shape of a flick.
send(&mut render, &mut rsc, 80.0, ActivationState::Start);
send(
&mut render,
&mut rsc,
80.0 - (DRAG_SLOP + 40.0),
ActivationState::On,
);
let after_first = rsc.ui.widgets.get(&scroll).unwrap().amt();
assert!((after_first - 40.0).abs() < 0.01, "amt={after_first}");
send(&mut render, &mut rsc, 400.0, ActivationState::End);
assert_eq!(
pointer_input(&mut rsc).holder(),
None,
"the release must give the pointer back"
);
// A second gesture, starting where the first one did. If the arbiter
// were still panning from the release position, this first frame
// would apply the whole distance between the two at once.
send(&mut render, &mut rsc, 80.0, ActivationState::Start);
let after_second = rsc.ui.widgets.get(&scroll).unwrap().amt();
assert!(
(after_second - after_first).abs() < 0.01,
"a fresh touch-down moved the content by {} -- the previous \
gesture was never closed",
after_second - after_first,
);
}
/// The second half of the same report: "tapping sometimes seems to make
/// the scrolling jump, particularly when tapping on things that have
/// events like horizontal scrolling."
///
/// Two widgets see the same press -- a scroll area and, under it,
/// something tracking the gesture for a list. When the scroll area
/// captures, the other one is cut off completely: no `PressEnd`, no
/// `Drop`. It has to be told, or its gesture stays open at an origin
/// belonging to a finger that has long gone, and the next unrelated touch
/// is measured from it.
#[test]
fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// The bystander *contains* the capturer, which is the real shape: a
// transcript's `LazySpan` and one row's own text both track the same
// press, and a `Stack`'s siblings would be on separate layers where
// only the topmost is dispatched to at all.
let capturer = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let capturer_weak = capturer.weak();
let bystander = rsc.ui.widgets.add_strong(Stack {
children: vec![capturer.any()],
size: StackSize::default(),
});
let bystander_weak = bystander.weak();
let capturer_saw = Rc::new(Cell::new(0u32));
{
let capturer_saw = capturer_saw.clone();
rsc.register_event(
capturer_weak,
CursorSense::drag_senses(),
move |ctx, _rsc| {
capturer_saw.set(capturer_saw.get() + 1);
if matches!(ctx.data.sense, CursorSense::Pressing(_)) {
ctx.data.pointer.capture(capturer_weak.id());
}
},
);
}
let cancelled = Rc::new(Cell::new(0u32));
let ended = Rc::new(Cell::new(0u32));
{
let (cancelled, ended) = (cancelled.clone(), ended.clone());
rsc.register_event(
bystander_weak,
CursorSense::drag_senses(),
move |ctx, _rsc| match ctx.data.sense {
CursorSense::Cancel => cancelled.set(cancelled.get() + 1),
CursorSense::PressEnd(_) | CursorSense::Drop => ended.set(ended.get() + 1),
_ => {}
},
);
}
let root = bystander.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
let mut state = ();
let win = Vec2::new(100.0, 100.0);
let mut down = cursor_at((50.0, 50.0).into());
down.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, down, win);
render.update(&root, &mut rsc);
assert_eq!(cancelled.get(), 0, "nothing has captured yet");
let mut moved = cursor_at((50.0, 20.0).into());
moved.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, moved, win);
render.update(&root, &mut rsc);
assert!(capturer_saw.get() > 0, "the capturer never saw the press");
assert_eq!(
pointer_input(&mut rsc).holder(),
Some(capturer_weak.id()),
"the capture should have been taken on this frame"
);
assert_eq!(
cancelled.get(),
1,
"the widget that lost the gesture must be told exactly once"
);
// And exactly once: the frames after the capture reach the capturer
// alone, so there is nothing left to cancel.
let mut more = cursor_at((50.0, 10.0).into());
more.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, more, win);
render.update(&root, &mut rsc);
let mut up = cursor_at((50.0, 10.0).into());
up.buttons.left = ActivationState::End;
render.run_sensors(&mut rsc, &mut state, up, win);
render.update(&root, &mut rsc);
assert_eq!(cancelled.get(), 1, "cancelled more than once");
assert_eq!(
ended.get(),
0,
"a cancelled widget must not also be told the gesture ended \
normally -- acting on that is the tap it never made"
);
}
/// Iris's rule for nested scrolling, 2026-09-08: "it should only trigger
/// horizontal if you drag left or right, and vertical should fall through
/// if you drag up or down."
///
/// One mechanism does both, and it is `DragArbiter`'s existing axis test:
/// each scroll area's gesture commits only on its own axis, so a drag
/// along the other one is never claimed and the enclosing area's gesture
/// -- which sees the same press, being an ancestor rather than a sibling
/// layer -- is the one that commits and captures. This pins the pair,
/// including the direction the change had no reason to touch.
#[test]
fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() {
for (name, to, pans, still) in [
("vertical", Vec2::new(50.0, 80.0 - (DRAG_SLOP + 40.0)), 0, 1),
(
"horizontal",
Vec2::new(50.0 - (DRAG_SLOP + 40.0), 80.0),
1,
0,
),
] {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// 1000px square of content in a 100px window: room to pan either
// way, in an X area inside a Y one.
let seen = Rc::new(Cell::new(None));
let record = seen.clone();
let outer_strong = rect(UiColor::WHITE)
.width(Len::abs(1000.0))
.height(Len::abs(1000.0))
.scrollable(Axis::X, Pin::Start)
// The inner area's own handle, taken as the chain is built --
// the whole point is to exercise `scrollable`'s real
// registration on both, so neither is assembled by hand.
.with_id(move |_rsc, id| {
record.set(Some(id));
id
})
.scrollable(Axis::Y, Pin::Start)
.add_strong(&mut rsc);
let inner = seen.get().unwrap();
let outer = outer_strong.weak();
let root = outer_strong.any();
let areas = [outer, inner];
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
// The second frame, where each area knows its content length --
// LAYOUT.md section 4's one-frame lag, and what drops `snap_end`.
for a in areas {
rsc.ui.widgets.get_mut(&a).unwrap().scroll(0.0);
}
render.update(&root, &mut rsc);
let mut state = ();
let win = Vec2::new(100.0, 100.0);
let mut down = cursor_at((50.0, 80.0).into());
down.buttons.left = ActivationState::Start;
render.run_sensors(&mut rsc, &mut state, down, win);
render.update(&root, &mut rsc);
let mut drag = cursor_at(to);
drag.buttons.left = ActivationState::On;
render.run_sensors(&mut rsc, &mut state, drag, win);
render.update(&root, &mut rsc);
let moved = rsc.ui.widgets.get(&areas[pans]).unwrap().amt();
let unmoved = rsc.ui.widgets.get(&areas[still]).unwrap().amt();
assert!(
(moved - 40.0).abs() < 0.01,
"a {name} drag should have panned the {name} area by the 40px \
past the slop, got {moved}"
);
assert_eq!(
unmoved, 0.0,
"a {name} drag must not move the area that owns the other axis"
);
}
}
/// Iris's 2026-09-08 report: "if I try to scroll vertically while a
/// horizontal scroll animation is still active, it stays locked to the
/// horizontal scroll", with her own diagnosis -- "tapping outside of
/// something that a fling is currently active for should have no code in
/// common with the fling that could influence it."
///
/// She was right that it was global state, and this is where it lived.
/// `run_sensors` runs a widget one more frame *after* the pointer has
/// left it, so a `HoverEnd` can fire ([`ActivationState::End`], which is
/// not `Off`) -- and `should_run` derived a press from the button alone,
/// so that farewell frame also carried a `PressStart`. A widget nowhere
/// near the finger therefore opened a gesture, and a `ScrollArea` catching
/// its own fling commits with no slop, so it captured the pointer and the
/// whole gesture went to it.
///
/// Two areas side by side here rather than one, because "the press went
/// to the wrong widget" and "the press went nowhere" are different
/// failures and only the second area can tell them apart.
#[test]
fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
let mut rsc = SenseRsc {
ui: UiData::default(),
events: EventManager::default(),
};
// Two 1000px-tall scroll areas, stacked: the top half of the window
// is the first, the bottom half the second. Each area's own handle is
// taken as its chain is built (`with_id`, the same way the nested-axes
// test above does it), since what is under test is `scrollable()`'s
// real registration rather than a `ScrollArea` assembled by hand.
let seen: [Rc<Cell<Option<WeakWidget<ScrollArea>>>>; 2] = Default::default();
let half = |slot: &Rc<Cell<Option<WeakWidget<ScrollArea>>>>| {
let record = slot.clone();
rect(UiColor::WHITE)
.height(Len::abs(1000.0))
.scrollable(Axis::Y, Pin::Start)
.with_id(move |_rsc, id| {
record.set(Some(id));
id
})
.height(Len::rel(0.5))
};
let root = (half(&seen[0]), half(&seen[1]))
.span(Dir::DOWN)
.add_strong(&mut rsc)
.any();
let (top_w, bottom_w) = (seen[0].get().unwrap(), seen[1].get().unwrap());
let win: Vec2 = (100.0, 200.0).into();
let mut render = UiRenderState::new();
render.resize((win.x, win.y));
render.update(&root, &mut rsc);
// The second frame is the first that knows how long the content is --
// see `a_finger_drag_over_a_scroll_area_pans_it`.
for w in [&top_w, &bottom_w] {
rsc.ui.widgets.get_mut(w).unwrap().scroll(0.0);
}
render.update(&root, &mut rsc);
let mut state = ();
// Flick the top area and let go: it is left flinging, and -- because
// the release goes through `run_sensors`' capture branch, which
// returns before the loop that would have updated anybody's hover --
// its sensor is left `On` with the pointer no longer on it. Both
// halves of the real gesture, since both are what the bug needs.
let base = Instant::now();
let mut t = 0;
let sample = |render: &mut UiRenderState,
rsc: &mut SenseRsc,
state: &mut (),
y: f32,
button: ActivationState,
at_ms: u64| {
let mut c = cursor_at((50.0, y).into());
c.buttons.left = button;
c.time = base + std::time::Duration::from_millis(at_ms);
render.run_sensors(rsc, state, c, win);
render.update(&root, rsc);
};
sample(
&mut render,
&mut rsc,
&mut state,
50.0,
ActivationState::Start,
t,
);
for y in [44.0, 32.0, 14.0] {
t += 8;
sample(&mut render, &mut rsc, &mut state, y, ActivationState::On, t);
}
t += 8;
sample(
&mut render,
&mut rsc,
&mut state,
14.0,
ActivationState::End,
t,
);
assert!(
rsc.ui.widgets.get(&top_w).unwrap().is_scrolling(),
"the flick must leave the top area coasting -- the press below is \
only dangerous while something is still moving",
);
let flung_to = rsc.ui.widgets.get(&top_w).unwrap().amt();
// Now press and drag in the *bottom* area: the top area's hover
// decays to `End` on this very sample, which is the frame that used
// to carry a `PressStart` to it.
t += 8;
sample(
&mut render,
&mut rsc,
&mut state,
150.0,
ActivationState::Start,
t,
);
t += 8;
sample(
&mut render,
&mut rsc,
&mut state,
150.0 - (DRAG_SLOP + 40.0),
ActivationState::On,
t,
);
let moved = rsc.ui.widgets.get(&bottom_w).unwrap().amt();
assert!(
(moved - 40.0).abs() < 0.01,
"the area actually under the finger should have panned by the 40px \
past the slop, got {moved}"
);
assert_eq!(
rsc.ui.widgets.get(&top_w).unwrap().amt(),
flung_to,
"the area the pointer had left must not have seen the press at all -- \
a catch would have stopped its fling on the touch-down"
);
assert_eq!(
pointer_input(&mut rsc).holder(),
Some(bottom_w.id()),
"the gesture belongs to the widget under the finger",
);
}
+38 -1
View File
@@ -1,7 +1,8 @@
use iris_core::{ use iris_core::{
WidgetId, UiRsc, WidgetId,
util::{HashMap, HashSet}, util::{HashMap, HashSet},
}; };
use iris_core::{WeakWidget, Widget};
use std::{ use std::{
any::{Any, TypeId}, any::{Any, TypeId},
marker::PhantomData, marker::PhantomData,
@@ -73,3 +74,39 @@ impl<'a, T: 'static> FnOnce<(&'a mut WidgetState,)> for WeakState<T> {
state.get_mut(self) state.get_mut(self)
} }
} }
/// What `Rsc[weak_handle]` indexes through -- one impl per kind of handle
/// (a widget, a piece of per-widget state), shared by both backends' `Rsc`
/// types since indexing a widget tree has nothing to do with windowing.
/// Each backend still needs its own `Index`/`IndexMut for ItsRsc<State>`
/// (`default/mod.rs`, `android/view.rs`), because a blanket impl over every
/// `I: RscIdx<Rsc>` for every possible `Rsc` would conflict between crates.
pub trait RscIdx<Rsc> {
type Output;
fn get(self, rsc: &Rsc) -> &Self::Output;
fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output;
}
impl<W: Widget, Rsc: UiRsc> RscIdx<Rsc> for WeakWidget<W> {
type Output = W;
fn get(self, rsc: &Rsc) -> &Self::Output {
&rsc.ui().widgets[self]
}
fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output {
&mut rsc.ui_mut().widgets[self]
}
}
impl<T: 'static, Rsc: HasWidgetState> RscIdx<Rsc> for WeakState<T> {
type Output = T;
fn get(self, rsc: &Rsc) -> &Self::Output {
rsc.widget_state().get(self)
}
fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output {
rsc.widget_state_mut().get_mut(self)
}
}
+28 -6
View File
@@ -13,7 +13,17 @@ use tokio::{
unbounded_channel as async_channel, unbounded_channel as async_channel,
}, },
}; };
use winit::window::Window;
/// What a completed task nudges when it wants its result drawn. Shared
/// between backends rather than typed as `winit::window::Window` directly:
/// android-view has no `Window` at all, and the redraw request there is a
/// JNI call (`View::post_frame_callback`) rather than a method call on a
/// value this crate owns. Each backend supplies its own implementation --
/// `default/render.rs` for winit, `android/render.rs` for android-view --
/// and this module never needs to know which one it is holding.
pub trait RequestRedraw: Send + Sync + 'static {
fn request_redraw(&self);
}
pub type TaskMsgSender<Rsc> = SyncSender<Box<dyn TaskUpdate<Rsc>>>; pub type TaskMsgSender<Rsc> = SyncSender<Box<dyn TaskUpdate<Rsc>>>;
pub type TaskMsgReceiver<Rsc> = SyncReceiver<Box<dyn TaskUpdate<Rsc>>>; pub type TaskMsgReceiver<Rsc> = SyncReceiver<Box<dyn TaskUpdate<Rsc>>>;
@@ -23,7 +33,7 @@ impl<F: FnOnce(&mut Rsc::State, &mut Rsc) + Send, Rsc: HasState> TaskUpdate<Rsc>
pub struct Tasks<Rsc: HasState> { pub struct Tasks<Rsc: HasState> {
start: AsyncSender<BoxTask>, start: AsyncSender<BoxTask>,
window: Arc<Window>, redraw: Arc<dyn RequestRedraw>,
msg_send: SyncSender<Box<dyn TaskUpdate<Rsc>>>, msg_send: SyncSender<Box<dyn TaskUpdate<Rsc>>>,
} }
@@ -45,7 +55,7 @@ impl<Rsc: HasState + 'static> TaskCtx<Rsc> {
type BoxTask = Pin<Box<dyn Future<Output = ()> + Send>>; type BoxTask = Pin<Box<dyn Future<Output = ()> + Send>>;
impl<Rsc: HasState> Tasks<Rsc> { impl<Rsc: HasState> Tasks<Rsc> {
pub fn init(window: Arc<Window>) -> (Self, TaskMsgReceiver<Rsc>) { pub fn init(redraw: Arc<dyn RequestRedraw>) -> (Self, TaskMsgReceiver<Rsc>) {
let (start, start_recv) = async_channel(); let (start, start_recv) = async_channel();
let (msgs, msgs_recv) = sync_channel(); let (msgs, msgs_recv) = sync_channel();
std::thread::spawn(|| { std::thread::spawn(|| {
@@ -56,21 +66,33 @@ impl<Rsc: HasState> Tasks<Rsc> {
Self { Self {
start, start,
msg_send: msgs, msg_send: msgs,
window, redraw,
}, },
msgs_recv, msgs_recv,
) )
} }
/// The same redraw handle `spawn`'s wrapper calls once, after a whole
/// task's future completes -- exposed so a caller running its own
/// longer-lived loop *inside* a spawned task (a live SSE follow, here)
/// can ask for a frame after each `TaskCtx::update`, not just at the
/// end. Without this a caller has no way to get a redraw mid-stream,
/// which is exactly the gap `iris/desktop-app`'s `app.rs` module doc
/// names for why it uses winit's `Proxy` instead of `Tasks` -- Android
/// has no `Proxy`, so this is what closes the same gap there.
pub fn redraw_handle(&self) -> Arc<dyn RequestRedraw> {
self.redraw.clone()
}
pub fn spawn<F: AsyncFnOnce(TaskCtx<Rsc>) + 'static + std::marker::Send>(&mut self, task: F) pub fn spawn<F: AsyncFnOnce(TaskCtx<Rsc>) + 'static + std::marker::Send>(&mut self, task: F)
where where
F::CallOnceFuture: Send, F::CallOnceFuture: Send,
{ {
let send = self.msg_send.clone(); let send = self.msg_send.clone();
let window = self.window.clone(); let redraw = self.redraw.clone();
let _ = self.start.send(Box::pin(async move { let _ = self.start.send(Box::pin(async move {
task(TaskCtx::new(send)).await; task(TaskCtx::new(send)).await;
window.request_redraw(); redraw.request_redraw();
})); }));
} }
} }
+11 -8
View File
@@ -6,16 +6,19 @@ pub struct Image {
} }
impl Widget for Image { impl Widget for Image {
fn draw(&mut self, painter: &mut Painter) { fn draw(&mut self, painter: &mut Painter) -> Size {
painter.texture(&self.handle); // Drawn at its own natural size, anchored top-left of whatever it
// was offered, not stretched to fill it -- its primitive is
// independent of the offered region, matching `is_size_independent`
// below. A caller that wants it placed differently wraps it (e.g.
// `.center()`, `.align(...)`).
let size = self.handle.size();
painter.texture_within(&self.handle, size.align(Align::TOP_LEFT));
Size::abs(size)
} }
fn desired_width(&mut self, _: &mut SizeCtx) -> Len { fn is_size_independent(&self) -> bool {
Len::abs(self.handle.size().x) true // a decoded image's primitive never depends on the region it is offered
}
fn desired_height(&mut self, _: &mut SizeCtx) -> Len {
Len::abs(self.handle.size().y)
} }
} }
+25 -9
View File
@@ -1,20 +1,36 @@
use crate::prelude::*; use crate::prelude::*;
/// Clips `inner` -- and everything below it -- to a shape.
///
/// The shape is a **primitive**, never a rectangle or a radius stored
/// here: with `shape`, the widget named there is drawn behind `inner`
/// filling the same box and the clip is its first primitive, so a rounded
/// container's corner and the corner its content is cut to are the same
/// arithmetic and cannot fall out of step. Without one, this writes an
/// undrawn rect at its own region, which is the plain "clip to my box"
/// every list and scroll area wants. See docs/LAYOUT.md's "Masks with a
/// shape".
pub struct Masked { pub struct Masked {
/// The widget whose first primitive is the clip, drawn behind
/// `inner`, or `None` for this widget's own box.
pub shape: Option<StrongWidget>,
pub inner: StrongWidget, pub inner: StrongWidget,
} }
impl Widget for Masked { impl Widget for Masked {
fn draw(&mut self, painter: &mut Painter) { fn draw(&mut self, painter: &mut Painter) -> Size {
painter.set_mask(painter.region()); match &self.shape {
painter.widget(&self.inner); // Layered the way `Stack` layers a background under its
// content, and for the same reason: within one layer the draw
// order is undefined once anything has been freed.
Some(shape) => {
painter.child_layer();
painter.widget(shape);
painter.set_mask_to_widget(shape);
painter.next_layer();
} }
None => painter.set_mask(painter.region()),
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.width(&self.inner)
} }
painter.widget(&self.inner)
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.height(&self.inner)
} }
} }
+17 -16
View File
@@ -6,30 +6,31 @@ pub struct Aligned {
} }
impl Widget for Aligned { impl Widget for Aligned {
fn draw(&mut self, painter: &mut Painter) { fn draw(&mut self, painter: &mut Painter) -> Size {
// Draw once at the whole region this widget was offered to learn
// the child's real size -- this placement is provisional and
// corrected below without a second draw. `painter.widget` (not
// `widget_within(..., painter.region())`) is what "my whole,
// already-resolved region, unmodified" means: `widget_within`
// composes its argument as a *local*, `UiRegion::FULL`-relative
// box against `painter.region()`, so handing it the
// already-resolved region double-applies that composition and is
// wrong for any widget nested below the root.
let used = painter.widget(&self.inner);
let density = painter.density();
let region = match self.align.tuple() { let region = match self.align.tuple() {
(Some(x), Some(y)) => painter (Some(x), Some(y)) => used.to_uivec2(density).align(RegionAlign { x, y }),
.size(&self.inner)
.to_uivec2()
.align(RegionAlign { x, y }),
(Some(x), None) => { (Some(x), None) => {
let x = painter.size_ctx().width(&self.inner).apply_rest().align(x); let x = used.x.apply_rest(density).align(x);
UiRegion::new(x, UiSpan::FULL) UiRegion::new(x, UiSpan::FULL)
} }
(None, Some(y)) => { (None, Some(y)) => {
let y = painter.size_ctx().height(&self.inner).apply_rest().align(y); let y = used.y.apply_rest(density).align(y);
UiRegion::new(UiSpan::FULL, y) UiRegion::new(UiSpan::FULL, y)
} }
(None, None) => UiRegion::FULL, (None, None) => UiRegion::FULL,
}; };
painter.widget_within(&self.inner, region); painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
} used
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.width(&self.inner)
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.height(&self.inner)
} }
} }
+2 -10
View File
@@ -6,18 +6,10 @@ pub struct LayerOffset {
} }
impl Widget for LayerOffset { impl Widget for LayerOffset {
fn draw(&mut self, painter: &mut Painter) { fn draw(&mut self, painter: &mut Painter) -> Size {
for _ in 0..self.offset { for _ in 0..self.offset {
painter.next_layer(); painter.next_layer();
} }
painter.widget(&self.inner); painter.widget(&self.inner)
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.width(&self.inner)
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.height(&self.inner)
} }
} }
File diff suppressed because it is too large. Load diff
+45 -30
View File
@@ -7,42 +7,57 @@ pub struct MaxSize {
} }
impl MaxSize { impl MaxSize {
fn apply_to_outer(&self, ctx: &mut SizeCtx) { /// Caps a reported length at `max`, comparing in pixels since `Len`'s
if let Some(x) = self.x { /// rel/abs/rest components are not otherwise comparable.
ctx.outer.x.select_len(x.apply_rest()); fn clamp(len: Len, max: Option<Len>, output: f32, density: f32) -> Len {
let Some(max) = max else {
return len;
};
let len_px = len.apply_rest(density).to_abs(output);
let max_px = max.apply_rest(density).to_abs(output);
// `fold_dp`, not the caller's `max` as written: a reported `Len`
// may not carry an unresolved `dp` -- see `Len::fold_dp` for the
// collapsed composer bar this caused.
if len_px > max_px {
max.fold_dp(density)
} else {
len
} }
if let Some(y) = self.y { }
ctx.outer.y.select_len(y.apply_rest());
/// The span (in this widget's own local, `UiRegion::FULL`-relative
/// terms) to actually offer the child: unconstrained if it already fits
/// within `max`, or a box of exactly `max`, anchored at this axis's
/// start, if it does not. Needed so the child is never painted bigger
/// than the size this widget reports for it -- see the identical
/// requirement noted on `Sized::draw`.
fn clamp_region(offered_px: f32, max: Option<Len>, output: f32, density: f32) -> UiSpan {
let Some(max) = max else {
return UiSpan::FULL;
};
let max_scalar = max.apply_rest(density);
let max_px = max_scalar.to_abs(output);
if offered_px > max_px {
max_scalar.align(AxisAlign::Neg)
} else {
UiSpan::FULL
} }
} }
} }
impl Widget for MaxSize { impl Widget for MaxSize {
fn draw(&mut self, painter: &mut Painter) { fn draw(&mut self, painter: &mut Painter) -> Size {
painter.widget(&self.inner); let output = painter.output_size();
} let density = painter.density();
let offered = painter.px_size();
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { let region = UiRegion {
self.apply_to_outer(ctx); x: Self::clamp_region(offered.x, self.x, output.x, density),
let width = ctx.width(&self.inner); y: Self::clamp_region(offered.y, self.y, output.y, density),
if let Some(x) = self.x { };
let width_px = width.apply_rest().to_abs(ctx.output_size().x); let used = painter.widget_within(&self.inner, region);
let x_px = x.apply_rest().to_abs(ctx.output_size().x); Size {
if width_px > x_px { x } else { width } x: Self::clamp(used.x, self.x, output.x, density),
} else { y: Self::clamp(used.y, self.y, output.y, density),
width
}
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
self.apply_to_outer(ctx);
let height = ctx.height(&self.inner);
if let Some(y) = self.y {
let height_px = height.apply_rest().to_abs(ctx.output_size().y);
let y_px = y.apply_rest().to_abs(ctx.output_size().y);
if height_px > y_px { y } else { height }
} else {
height
} }
} }
} }
+6 -2
View File
@@ -1,19 +1,23 @@
mod align; mod align;
mod layer; mod layer;
mod lazy_span;
mod max_size; mod max_size;
mod offset; mod offset;
mod pad; mod pad;
mod scroll; mod scroll_area;
mod scrollable;
mod sized; mod sized;
mod span; mod span;
mod stack; mod stack;
pub use align::*; pub use align::*;
pub use layer::*; pub use layer::*;
pub use lazy_span::*;
pub use max_size::*; pub use max_size::*;
pub use offset::*; pub use offset::*;
pub use pad::*; pub use pad::*;
pub use scroll::*; pub use scroll_area::*;
pub use scrollable::*;
pub use sized::*; pub use sized::*;
pub use span::*; pub use span::*;
pub use stack::*; pub use stack::*;
+2 -10
View File
@@ -6,16 +6,8 @@ pub struct Offset {
} }
impl Widget for Offset { impl Widget for Offset {
fn draw(&mut self, painter: &mut Painter) { fn draw(&mut self, painter: &mut Painter) -> Size {
let region = UiRegion::FULL.offset(self.amt); let region = UiRegion::FULL.offset(self.amt);
painter.widget_within(&self.inner, region); painter.widget_within(&self.inner, region)
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.width(&self.inner)
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.height(&self.inner)
} }
} }
+61 -62
View File
@@ -6,48 +6,43 @@ pub struct Pad {
} }
impl Widget for Pad { impl Widget for Pad {
fn draw(&mut self, painter: &mut Painter) { fn draw(&mut self, painter: &mut Painter) -> Size {
painter.widget_within(&self.inner, self.padding.region()); let density = painter.density();
let used = painter.widget_within(&self.inner, self.padding.region(density));
let width =
self.padding.left.apply_rest(density).abs + self.padding.right.apply_rest(density).abs;
let height =
self.padding.top.apply_rest(density).abs + self.padding.bottom.apply_rest(density).abs;
Size {
x: used.x + Len::abs(width),
y: used.y + Len::abs(height),
} }
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
let width = self.padding.left + self.padding.right;
let height = self.padding.top + self.padding.bottom;
ctx.outer.x.abs -= width;
ctx.outer.y.abs -= height;
let mut size = ctx.width(&self.inner);
size.abs += width;
size
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
let width = self.padding.left + self.padding.right;
let height = self.padding.top + self.padding.bottom;
ctx.outer.x.abs -= width;
ctx.outer.y.abs -= height;
let mut size = ctx.height(&self.inner);
size.abs += height;
size
} }
} }
/// Each side is a `Len`, not a bare `f32`, so `.pad(dp(10))` resolves
/// against the display's density the same way any other size does -- see
/// `Len::dp`'s field doc. `.pad(10)` (a bare number) still works via
/// `From<T: UiNum>` below, unchanged: it becomes an `abs` (physical-pixel)
/// `Len`, exactly as a bare number always has meant elsewhere in this
/// crate.
pub struct Padding { pub struct Padding {
pub left: f32, pub left: Len,
pub right: f32, pub right: Len,
pub top: f32, pub top: Len,
pub bottom: f32, pub bottom: Len,
} }
impl Padding { impl Padding {
pub const ZERO: Self = Self { pub const ZERO: Self = Self {
left: 0.0, left: Len::ZERO,
right: 0.0, right: Len::ZERO,
top: 0.0, top: Len::ZERO,
bottom: 0.0, bottom: Len::ZERO,
}; };
pub fn uniform(amt: impl UiNum) -> Self { pub fn uniform(amt: impl Into<Len>) -> Self {
let amt = amt.to_f32(); let amt = amt.into();
Self { Self {
left: amt, left: amt,
right: amt, right: amt,
@@ -55,80 +50,84 @@ impl Padding {
bottom: amt, bottom: amt,
} }
} }
pub fn region(&self) -> UiRegion { pub fn region(&self, density: f32) -> UiRegion {
let mut region = UiRegion::FULL; let mut region = UiRegion::FULL;
region.x.start.abs += self.left; region.x.start.abs += self.left.apply_rest(density).abs;
region.y.start.abs += self.top; region.y.start.abs += self.top.apply_rest(density).abs;
region.x.end.abs -= self.right; region.x.end.abs -= self.right.apply_rest(density).abs;
region.y.end.abs -= self.bottom; region.y.end.abs -= self.bottom.apply_rest(density).abs;
region region
} }
pub fn x(amt: impl UiNum) -> Self { pub fn x(amt: impl Into<Len>) -> Self {
let amt = amt.to_f32(); let amt = amt.into();
Self { Self {
left: amt, left: amt,
right: amt, right: amt,
top: 0.0, top: Len::ZERO,
bottom: 0.0, bottom: Len::ZERO,
} }
} }
pub fn y(amt: impl UiNum) -> Self { pub fn y(amt: impl Into<Len>) -> Self {
let amt = amt.to_f32(); let amt = amt.into();
Self { Self {
left: 0.0, left: Len::ZERO,
right: 0.0, right: Len::ZERO,
top: amt, top: amt,
bottom: amt, bottom: amt,
} }
} }
pub fn top(amt: impl UiNum) -> Self { pub fn top(amt: impl Into<Len>) -> Self {
let mut s = Self::ZERO; let mut s = Self::ZERO;
s.top = amt.to_f32(); s.top = amt.into();
s s
} }
pub fn bottom(amt: impl UiNum) -> Self { pub fn bottom(amt: impl Into<Len>) -> Self {
let mut s = Self::ZERO; let mut s = Self::ZERO;
s.bottom = amt.to_f32(); s.bottom = amt.into();
s s
} }
pub fn left(amt: impl UiNum) -> Self { pub fn left(amt: impl Into<Len>) -> Self {
let mut s = Self::ZERO; let mut s = Self::ZERO;
s.left = amt.to_f32(); s.left = amt.into();
s s
} }
pub fn right(amt: impl UiNum) -> Self { pub fn right(amt: impl Into<Len>) -> Self {
let mut s = Self::ZERO; let mut s = Self::ZERO;
s.right = amt.to_f32(); s.right = amt.into();
s s
} }
pub fn with_top(mut self, amt: impl UiNum) -> Self { pub fn with_top(mut self, amt: impl Into<Len>) -> Self {
self.top = amt.to_f32(); self.top = amt.into();
self self
} }
pub fn with_bottom(mut self, amt: impl UiNum) -> Self { pub fn with_bottom(mut self, amt: impl Into<Len>) -> Self {
self.bottom = amt.to_f32(); self.bottom = amt.into();
self self
} }
pub fn with_left(mut self, amt: impl UiNum) -> Self { pub fn with_left(mut self, amt: impl Into<Len>) -> Self {
self.left = amt.to_f32(); self.left = amt.into();
self self
} }
pub fn with_right(mut self, amt: impl UiNum) -> Self { pub fn with_right(mut self, amt: impl Into<Len>) -> Self {
self.right = amt.to_f32(); self.right = amt.into();
self self
} }
} }
impl<T: UiNum> From<T> for Padding { /// Covers both a bare number (`.pad(8)`, via `Len`'s own `From<N: UiNum>`
/// blanket -- an `abs`/physical-pixel `Len`) and a `Len` directly
/// (`.pad(dp(10))`) with the one impl, since `Len: Into<Len>` is the
/// reflexive case of the same bound.
impl<T: Into<Len>> From<T> for Padding {
fn from(amt: T) -> Self { fn from(amt: T) -> Self {
Self::uniform(amt.to_f32()) Self::uniform(amt.into())
} }
} }
-66
View File
@@ -1,66 +0,0 @@
use crate::prelude::*;
pub struct Scroll {
inner: StrongWidget,
axis: Axis,
amt: f32,
snap_end: bool,
container_len: f32,
content_len: f32,
}
impl Widget for Scroll {
fn draw(&mut self, painter: &mut Painter) {
let output_len = painter.output_size().axis(self.axis);
let container_len = painter.region().axis(self.axis).len();
let content_len = painter
.len_axis(&self.inner, self.axis)
.apply_rest()
.within_len(container_len)
.to_abs(output_len);
self.container_len = container_len.to_abs(output_len);
self.content_len = content_len;
if self.snap_end {
self.amt = self.content_len - self.container_len;
}
self.update_amt();
let mut region = UiRegion::FULL.offset(Vec2::from_axis(self.axis, -self.amt, 0.0));
region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(self.content_len);
painter.widget_within(&self.inner, region);
}
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.width(&self.inner)
}
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
ctx.height(&self.inner)
}
}
impl Scroll {
pub fn new(inner: StrongWidget, axis: Axis) -> Self {
Self {
inner,
axis,
amt: 0.0,
snap_end: true,
container_len: 0.0,
content_len: 0.0,
}
}
pub fn update_amt(&mut self) {
self.amt = self.amt.max(0.0);
let len = (self.content_len - self.container_len).max(0.0);
self.amt = self.amt.min(len);
self.snap_end = self.amt == len;
}
pub fn scroll(&mut self, amt: f32) {
self.amt -= amt;
self.update_amt();
}
}
+565
View File
@@ -0,0 +1,565 @@
//! `ScrollArea`: a fixed child, slid about by a [`ScrollController`].
//!
//! **`docs/SCROLL.md` is the overview** -- the one sign convention, what
//! `amt` means, and how this differs from a `LazySpan`, which scrolls
//! itself. Read it first; this file is the detail.
use crate::prelude::*;
use std::time::Instant;
/// A scrolling view over a child that is a fixed lump: it is measured
/// whole and then moved, which is what makes a scroll tick an O(1) move of
/// one subtree rather than a redraw.
///
/// **"Area" because it only scrolls a predefined one** (Iris, 2026-09-08):
/// a child that lays out lazily cannot be measured whole or moved as a
/// lump, and virtualising it inside one of these would never update which
/// rows it shows, since a scroll tick offers a same-size moved region and
/// `draw_inner` never re-enters the child. That case is `LazySpan`, which
/// owns a controller of its own instead of being wrapped in one of these.
pub struct ScrollArea {
inner: StrongWidget,
/// The position, the gesture, the fling and the pin -- everything
/// about scrolling that is not this widget's own layout, shared with
/// `LazySpan` rather than reimplemented beside it.
ctl: ScrollController,
container_len: f32,
/// How long the content is along the axis, as of the last draw --
/// `None` until this widget has drawn once.
///
/// An `Option` rather than a `0.0` that stands in for both, because
/// the two answers led somewhere different and the code could not tell
/// them apart: on the first frame the clamp computed a scroll range of
/// zero, concluded from `amt == range` that the area was sitting at
/// its end, and pinned it -- so the next frame, now knowing the real
/// length, jumped to it. A code fence therefore opened at the end of
/// its longest line, mid-word (`iris/run-headless.sh phone`,
/// 2026-09-08).
content_len: Option<f32>,
}
impl Scrollable for ScrollArea {
fn controller(&self) -> &ScrollController {
&self.ctl
}
fn controller_mut(&mut self) -> &mut ScrollController {
&mut self.ctl
}
}
impl Widget for ScrollArea {
/// A scroll area animates exactly one thing, its fling. The
/// registration that makes this run is `UiData::animate`, which
/// `WidgetLike::scrollable`'s own drag handler calls the frame a
/// release starts one.
fn tick(&mut self, now: Instant) -> bool {
self.tick_fling(now)
}
/// Measure, then place -- the same idiom `LazySpan` uses, for the same
/// reason: nothing drawn may depend on a length measured last frame.
///
/// **The child is drawn twice, and only the second decides anything.**
/// The first is handed last frame's length as a *hint*, and it exists
/// only so that the usual case, where the content's length did not
/// change, offers the same region twice: `draw_inner` then makes the
/// first call an O(1) `mov` and returns at the first line of the
/// second. A frame on which the content did grow or shrink pays one
/// real extra draw, and that is a frame on which the content was being
/// redrawn anyway.
///
/// The alternative -- place against the hint and let the next frame
/// fix it -- is what Iris found on her phone (2026-09-08): every
/// newline typed into the composer drew the field in a box one line
/// short of its text, and since that text is centred in its box it
/// hung half a line past each end. There was no next frame: nothing
/// dirtied that subtree again, so the stale placement was the last one
/// drawn, until the keyboard closed and its inset rewrite forced a
/// redraw ("it fixes itself"). **Layout is a pure function of the
/// state, not of how many frames have been drawn** (Iris, 2026-09-08)
/// -- a correction that needs a second frame is a frame drawn wrong.
fn draw(&mut self, painter: &mut Painter) -> Size {
// Every length here is resolved against the box this widget was
// **offered** (`px_size`), never `output_size`: a scroll area is
// routinely smaller than the window -- the composer's field is
// capped at six lines by a `MaxSize` around it -- and measuring
// the window instead would make the pan range, and so where the
// content sits, a function of the screen rather than of the box.
let axis = self.ctl.axis();
let container_len = painter.px_size().axis(axis);
self.container_len = container_len;
// Learned from the frame rather than passed in: a fling's
// deceleration is a physical quantity and needs the real display
// density, and `draw` is where this widget meets the only thing
// that knows it.
self.ctl.set_density(painter.density());
// Where the delta asked for since the last frame puts the content.
// Already inside the range the previous frame published, so it is
// the position to *measure* against; the clamp below is what the
// length just measured has to say about it.
let delta = self.ctl.take_delta();
let travelled = self.ctl.amt() - delta;
self.ctl.set_amt(travelled);
// The container's own length stands in as the hint until anything
// has been measured: a zero-length region on the first frame would
// place the child's primitives against a box of no size.
let hint = self.content_len.unwrap_or(container_len);
let used = painter.widget_within(&self.inner, self.child_region(hint));
// A child reporting `rel` means "this fraction of what I was
// offered", and what it was offered is this scroll area -- so the
// container, again, is what that resolves against.
let measured = used
.axis(axis)
.apply_rest(painter.density())
.to_abs(container_len);
self.content_len = Some(measured);
let range = (measured - container_len).max(0.0);
// The end-pin, and then the clamp, against the length just
// measured. Deliberately not also run before the measuring draw
// above -- clamping against the hint would let a stale length
// reduce `amt` in a way this pass cannot undo, and then where the
// content sits would depend on the previous frame after all.
//
// Only a frame with no delta of its own re-pins: the pin means
// "stay flush with the end as the content grows", and a reader who
// just scrolled away from that end has said otherwise. (A delta
// cannot be moving *toward* the end here -- the travel published
// below is zero that way while pinned, so `take_delta` has already
// clipped it.)
let amt = if self.ctl.pinned_to_end() && delta == 0.0 {
range
} else {
travelled.clamp(0.0, range)
};
self.ctl.set_amt(amt);
self.ctl.set_pinned_to_end(amt >= range);
self.ctl.set_travel(Travel {
back: amt,
fwd: range - amt,
});
// The **content's** size, not the container's. A parent that can
// grow (the composer's bar) should hug the text until its own cap
// stops it, and reporting the container instead would make this
// widget's answer a function of the answer -- the bar is sized
// from what is reported here, so it collapses to nothing and never
// recovers. What keeps the content inside the offered box is the
// mask a caller puts around it (`.scrollable(..).masked()`), not
// this number.
painter.widget_within(&self.inner, self.child_region(measured))
}
}
impl ScrollArea {
/// `pin` says which end this area opens at and clings to -- see
/// [`Pin`], and `WidgetLike::scrollable`, which is how one of these is
/// normally built.
pub fn new(inner: StrongWidget, axis: Axis, pin: Pin) -> Self {
Self {
inner,
// A fixed child is laid out from the box's negative edge
// onward, always, so the end of its content is the positive
// one -- which is what makes `Pin::End` and `Pin::Pos` the
// same pin here and different ones in a reversed `LazySpan`.
ctl: ScrollController::new(Dir::new(axis, Sign::Pos), pin),
container_len: 0.0,
content_len: None,
}
}
/// Where the child sits for a given content length: a box that long
/// along the scroll axis, pulled back by `amt`. The length is taken as
/// a parameter rather than read from `content_len`, because `draw`
/// places twice -- once against last frame's length and once against
/// the one it has just measured -- and the two must be the same
/// arithmetic.
fn child_region(&self, content_len: f32) -> UiRegion {
let axis = self.ctl.axis();
let mut region = UiRegion::FULL;
region.axis_mut(axis).end = region.axis(axis).start.offset(content_len);
region.offset(Vec2::from_axis(axis, -self.ctl.amt(), 0.0))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout_tests::TestRsc;
use crate::sense::{CursorButton, DRAG_SLOP, PointerRequests};
use iris_core::UiData;
use std::time::Duration;
/// A scroll area with 1000px of content in a 100px box, drawn once and
/// settled somewhere in the middle so a drag has room in both
/// directions.
///
/// Built and rendered for real rather than assembled field by field,
/// because a delta is spent in `draw` now (the controller banks it, and
/// only a layout knows where the content ends) -- so a test that never
/// draws would watch `amt` never move and read that as a broken
/// gesture.
fn area() -> (Fixture, WidgetId) {
area_on(Axis::Y)
}
/// The same fixture on either axis -- a code fence pans sideways
/// through one of these exactly as a field pans down, and the pair of
/// them is what caught a fling that only worked vertically.
fn area_on(axis: Axis) -> (Fixture, WidgetId) {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
let id = fill.id();
let long = Some(Len::abs(1000.0));
let tall = rsc.ui.widgets.add_strong(Sized {
inner: fill,
x: (axis == Axis::X).then_some(long).flatten(),
y: (axis == Axis::Y).then_some(long).flatten(),
});
let area = rsc
.ui
.widgets
.add_strong(ScrollArea::new(tall.any(), axis, Pin::Start));
let weak = area.weak();
let root = area.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
let mut fixture = Fixture {
rsc,
area: weak,
root,
render,
};
// 400px in, which is the middle of the 900px of travel this
// content has.
fixture.get().scroll(-400.0);
fixture.draw();
assert!((fixture.amt() - 400.0).abs() < 0.01);
(fixture, id)
}
/// The area under test with everything needed to draw it -- the drag
/// tests all do the same three things (reach the widget, draw, read
/// `amt`) and each of the three is a line of arena plumbing.
struct Fixture {
rsc: TestRsc,
area: WeakWidget<ScrollArea>,
root: StrongWidget,
render: UiRenderState,
}
impl Fixture {
fn get(&mut self) -> &mut ScrollArea {
self.rsc.ui.widgets.get_mut(&self.area).unwrap()
}
fn draw(&mut self) {
self.render.update(&self.root, &mut self.rsc);
}
fn amt(&self) -> f32 {
self.rsc.ui.widgets.get(&self.area).unwrap().amt()
}
/// One frame of a fling, the way `UiData::tick_animations` drives
/// it: tick, then draw. Answers whether it is still going.
fn fling_frame(&mut self, now: Instant) -> bool {
let still = self.get().tick(now);
self.draw();
still
}
}
/// One frame of a touch gesture, followed by the draw that spends it.
fn press(f: &mut Fixture, id: WidgetId, sense: CursorSense, y: f32, t: Instant) {
drag(f, id, sense, Vec2::new(0.0, y), t);
}
/// The same, for a gesture whose position is not on the Y axis.
fn drag(f: &mut Fixture, id: WidgetId, sense: CursorSense, pos: Vec2, t: Instant) {
let pointer = PointerRequests::default();
let flung = f.get().drag(&pointer, id, sense, pos, t);
// What `WidgetLike::scrollable`'s own handler does with the
// answer, and the half a fling does not move without.
if flung {
let id = f.area.id();
f.rsc.ui.animate(id);
}
f.draw();
}
#[test]
fn a_vertical_finger_drag_pans_the_content_with_the_finger() {
let (mut f, id) = area();
let t = Instant::now();
press(
&mut f,
id,
CursorSense::PressStart(CursorButton::Left),
0.0,
t,
);
// Finger down by well past the slop: the content follows it down,
// which for this widget means *less* `amt`.
press(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
DRAG_SLOP + 30.0,
t + Duration::from_millis(20),
);
assert!(
(f.amt() - 370.0).abs() < 0.01,
"expected the 30px past the slop to be applied downward, got amt={}",
f.amt()
);
// ...and the next frame's motion is a plain per-frame delta.
press(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
DRAG_SLOP + 50.0,
t + Duration::from_millis(40),
);
assert!((f.amt() - 350.0).abs() < 0.01, "amt={}", f.amt());
}
/// The half the change had no reason to touch: a press that never
/// leaves the slop is a tap, and must move nothing at all -- otherwise
/// every tap on a scrollable field nudges its text.
#[test]
fn a_press_that_stays_inside_the_slop_does_not_scroll() {
let (mut f, id) = area();
let t = Instant::now();
press(
&mut f,
id,
CursorSense::PressStart(CursorButton::Left),
0.0,
t,
);
for (i, y) in [1.0, -2.0, DRAG_SLOP - 0.5].into_iter().enumerate() {
press(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
y,
t + Duration::from_millis(10 * (i as u64 + 1)),
);
}
press(
&mut f,
id,
CursorSense::PressEnd(CursorButton::Left),
DRAG_SLOP - 0.5,
t + Duration::from_millis(50),
);
assert!(
(f.amt() - 400.0).abs() < 0.01,
"a tap scrolled: amt={}",
f.amt()
);
}
/// A horizontal drag is not this widget's gesture: it must stay put
/// rather than pick up the vertical noise in a sideways swipe.
#[test]
fn a_horizontal_drag_does_not_scroll() {
let (mut f, id) = area();
let t = Instant::now();
drag(
&mut f,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::new(0.0, 0.0),
t,
);
drag(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(120.0, 3.0),
t + Duration::from_millis(20),
);
assert!((f.amt() - 400.0).abs() < 0.01, "amt={}", f.amt());
}
/// Panning stops at the ends of the content rather than running off,
/// which is `update_amt`'s clamp -- checked through `drag` so the two
/// cannot drift apart.
#[test]
fn a_pan_past_the_end_clamps_instead_of_running_off() {
let (mut f, id) = area();
let t = Instant::now();
drag(
&mut f,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::new(0.0, 0.0),
t,
);
drag(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 5000.0),
t + Duration::from_millis(20),
);
assert!((f.amt() - 0.0).abs() < 0.01, "amt={}", f.amt());
}
/// Iris, 2026-09-08: "Flinging doesn't work in horizontal scroll
/// areas. Flinging should be enabled by default in all scroll areas
/// on android to match composes behavior." A release with real
/// velocity coasts, decelerating, and settles on its own.
#[test]
fn a_released_pan_flings_and_settles() {
for axis in [Axis::X, Axis::Y] {
let (mut f, id) = area_on(axis);
let t = Instant::now();
let at = |d: f32| Vec2::from_axis(axis, d, 0.0);
drag(
&mut f,
id,
CursorSense::PressStart(CursorButton::Left),
at(0.0),
t,
);
// Four samples 8ms apart, accelerating away from the start --
// three is the fewest `VelocityTracker`'s quadratic fit can
// use, so this is a gesture that genuinely has a velocity.
for (i, d) in [-40.0, -100.0, -180.0, -280.0].into_iter().enumerate() {
drag(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
at(d),
t + Duration::from_millis(8 * (i as u64 + 1)),
);
}
let at_release = f.amt();
drag(
&mut f,
id,
CursorSense::PressEnd(CursorButton::Left),
at(-280.0),
t + Duration::from_millis(32),
);
assert!(
f.get().is_scrolling(),
"{axis:?}: a released pan with velocity must fling"
);
// Frames at 8ms until it stops, with each step no longer than
// the one before it -- a coast that does not decelerate is
// the linear-spline bug this crate has had once already.
let mut last_step = f32::INFINITY;
let mut ticks = 0;
let mut now = t + Duration::from_millis(32);
while f.fling_frame(now) {
let before = f.amt();
now += Duration::from_millis(8);
f.fling_frame(now);
let step = (f.amt() - before).abs();
assert!(
step <= last_step + 0.01,
"{axis:?}: the fling sped up: {last_step} then {step}"
);
last_step = step;
ticks += 1;
assert!(ticks < 10_000, "{axis:?}: the fling never settled");
}
assert!(
f.amt() > at_release,
"{axis:?}: the fling moved the content the wrong way: {at_release} -> {}",
f.amt()
);
}
}
/// The wall: a fling must not spend its remaining distance on content
/// that is not there. Released hard toward the start, it settles
/// exactly on it.
#[test]
fn a_fling_stops_at_the_end_of_the_content() {
// Both walls. A positive delta is applied as `amt -= delta`, so a
// positive velocity runs toward the start of the content and a
// negative one toward its end; 1000px of content in a 100px box
// leaves `amt` in 0..=900.
for (velocity, wall) in [(50_000.0f32, 0.0f32), (-50_000.0, 900.0)] {
let (mut f, _id) = area();
f.get().fling(velocity);
let t = Instant::now();
let mut now = t;
for _ in 0..1_000 {
if !f.fling_frame(now) {
break;
}
now += Duration::from_millis(8);
}
assert!(
!f.get().is_scrolling(),
"the fling toward {wall} ran past the content"
);
assert!(
(f.amt() - wall).abs() < 0.01,
"it should have settled on {wall}, got amt={}",
f.amt()
);
}
}
/// A finger on coasting content stops it there, from the first
/// sample, with no `DRAG_SLOP` to wait out -- the catch
/// `DragArbiter::press_start` describes, which a scroll area needs
/// for the same reason a list does now that it can coast at all.
#[test]
fn a_press_on_a_coasting_area_catches_it() {
let (mut f, id) = area();
f.get().fling(-4_000.0);
let t = Instant::now();
f.fling_frame(t);
f.fling_frame(t + Duration::from_millis(8));
let caught_at = f.amt();
assert!(f.get().is_scrolling(), "the fixture must still be moving");
let down = t + Duration::from_millis(16);
drag(
&mut f,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::new(0.0, 0.0),
down,
);
assert!(!f.get().is_scrolling(), "a touch-down must end the fling");
assert!(
(f.amt() - caught_at).abs() < 0.01,
"the down itself must not move the content, only stop it"
);
// A move well under `DRAG_SLOP` still tracks the finger, because
// this press caught something that was moving.
drag(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 2.0),
down + Duration::from_millis(8),
);
assert!(
(f.amt() - (caught_at - 2.0)).abs() < 0.01,
"a caught press must pan from its first sample: {} -> {}",
caught_at,
f.amt()
);
}
}
+524
View File
@@ -0,0 +1,524 @@
//! The scrolling capability: one `ScrollController` holding everything a
//! scroll position is made of, and a `Scrollable` trait for the widgets
//! that own one.
//!
//! **`docs/SCROLL.md` is the overview** -- the one sign convention, what
//! `amt` means, and which widgets scroll. Read it first; this file is the
//! detail.
//!
//! Two widgets scroll in iris and they scroll differently: a
//! [`ScrollArea`](super::ScrollArea) slides a fixed child about as a lump,
//! and a [`LazySpan`](super::LazySpan) lays its own rows out from an
//! anchor and cannot be slid at all. What they share is everything that is
//! *not* the layout -- the gesture, the fling, the pin, the position and
//! the account of how far it can still go -- so that lives here, in a
//! plain struct each of them contains, rather than in a protocol between
//! them (Iris, 2026-09-08: "what about adding a scroll controller that
//! both scroll and lazy span contain").
//!
//! The contract with the owner is two calls, both in its `draw`:
//!
//! 1. [`ScrollController::take_delta`] -- what a wheel, a drag or a fling
//! asked for since the last layout, already clamped to the travel the
//! owner last reported.
//! 2. [`ScrollController::set_travel`], plus whichever of
//! [`ScrollController::moved_by`] or [`ScrollController::set_amt`] fits
//! how that owner knows where it ended up -- movement for a layout with
//! no fixed origin, an absolute position for one that has.
//!
//! Everything between the two is the owner's own layout, and everything
//! outside them is the same for both.
use crate::prelude::*;
use crate::sense::{DragGesture, Flinger, GestureOutcome, PointerRequests, PressState};
use std::time::Instant;
/// Which end of its content a scroll area clings to as that content
/// grows, said either way round -- an enum rather than the `at_end: bool`
/// this used to be, because the flag sat at the end of two constructors
/// and `scrollable(axis, true)` says nothing at the call site about which
/// end `true` is.
///
/// **Two pairs, because there are two questions and they are not the same
/// one** (Iris, 2026-09-08: "that way you can select the pin based on the
/// axis's sign rather than the direction, so for example you can assure
/// it's always pinned to the bottom"):
///
/// - [`Pin::Start`] / [`Pin::End`] are **content-relative**: the first row
/// or the newest one, wherever the layout happens to put it. A
/// transcript wants `End` -- the newest message -- and does not care
/// which edge of the screen that is.
/// - [`Pin::Neg`] / [`Pin::Pos`] are **axis-absolute**: the top or left
/// edge, and the bottom or right one, whichever end of the content sits
/// there. What to reach for when the *screen* position is the
/// requirement.
///
/// The two coincide for content laid out along the positive axis, which is
/// everything except a reversed `LazySpan` (`Dir::UP`, `Dir::LEFT`) --
/// where they are exact opposites, which is the whole reason both exist.
#[derive(Clone, Copy, Eq, PartialEq, Debug)]
pub enum Pin {
/// The start of the content: item 0, wherever it is drawn.
Start,
/// The end of the content: the newest item, wherever it is drawn.
End,
/// The top or left edge of the box, whichever end of the content is
/// there.
Neg,
/// The bottom or right edge of the box, whichever end of the content
/// is there.
Pos,
}
impl Pin {
/// Resolve to the one question a scrollable actually acts on: does
/// content appended to the end bring the view with it? `dir` is the
/// way this owner's content runs, which is the only thing that tells
/// the axis-absolute pair from the content-relative one.
fn pinned_to_end(self, dir: Dir) -> bool {
match self {
Pin::Start => false,
Pin::End => true,
Pin::Neg => dir.sign == Sign::Neg,
Pin::Pos => dir.sign == Sign::Pos,
}
}
}
/// How far a scrollable can still travel from where it is, as of its last
/// layout, in the same screen-space units a delta is in.
///
/// `f32::INFINITY` where the end is not in sight: a lazy layout genuinely
/// does not know how much content lies past the rows it has walked, and
/// saying "infinity" is the honest answer that `clamp` also happens to
/// take with no branch. The wall is then found by the walk, which is why
/// the owner reports what it *did* as well as what it can do.
#[derive(Clone, Copy, Debug)]
pub struct Travel {
/// The bound on a **positive** delta -- scrolling up or left, back
/// toward the start of the content.
pub back: f32,
/// The bound on a **negative** delta -- scrolling down or right,
/// onward toward the end of the content. Positive itself: it is a
/// distance, and the sign it bounds is the caller's.
pub fwd: f32,
}
impl Travel {
/// Nothing known yet, so nothing is bounded -- what a scrollable
/// starts with and what it reports for an axis whose content it has
/// not measured.
pub const UNBOUNDED: Self = Self {
back: f32::INFINITY,
fwd: f32::INFINITY,
};
/// The bound on a delta of this sign, as a positive distance.
fn toward(&self, delta: f32) -> f32 {
if delta >= 0.0 { self.back } else { self.fwd }
}
}
/// The state a scroll position is made of, owned by the widget that
/// scrolls: where it is, what it was asked to do next, how far it can go,
/// which end it clings to, and the gesture and fling that drive it.
///
/// See the module doc for the two-call contract with its owner, and
/// [`Scrollable`] for the trait that reaches one.
pub struct ScrollController {
/// Which way this area's content runs: the axis it pans along, and the
/// sign the content grows in. A plain `ScrollArea` always grows the
/// positive way; a `LazySpan` passes its own `dir`, which is what
/// tells [`Pin::Pos`]/[`Pin::Neg`] from [`Pin::Start`]/[`Pin::End`].
dir: Dir,
/// Where this area has got to, counting **forward through the
/// content**: 0 at the start, growing as the reader moves on. The
/// opposite sign to a delta, which counts the way the finger moves.
///
/// For a `ScrollArea` it is a position, clamped into the content's
/// real length. For a `LazySpan` it is **movement, not position** --
/// paging rows in above moves the origin and the span cannot say by
/// how much, never having measured them -- so the direction is
/// comparable between the two and the absolute value is not.
amt: f32,
/// Asked for but not yet laid out: how far a wheel, a drag or a fling
/// has moved this area since the last draw. Taken and cleared by
/// [`Self::take_delta`], which is the only place it is spent, because
/// the owner's `draw` is the only place the walls are known.
pending: f32,
/// What the owner's last layout said was left, and what `take_delta`
/// clamps against.
travel: Travel,
/// Whether this area is currently flush against the end of its
/// content, so that content appended to it should bring the view
/// along. Set from [`Pin`] at construction and recomputed by the owner
/// at the end of every layout -- it is live state, not a preference: a
/// reader who scrolls away from the end stops being pinned to it, and
/// scrolling back re-pins.
pinned_to_end: bool,
/// Touch panning. Arbitration, `DRAG_SLOP` and pointer capture all
/// live in `sense.rs`; only what a committed pan *means* is decided
/// here. See [`Self::drag`].
gesture: DragGesture,
/// The momentum a release leaves behind. Every scroll area flings, on
/// either axis and with nothing to opt into -- Compose's `scrollable`
/// attaches `ScrollableDefaults.flingBehavior()` on every axis it is
/// given, and Iris asked for the same (2026-09-08: "flinging should be
/// enabled by default in all scroll areas on android to match composes
/// behavior").
fling: Flinger,
/// Physical pixels per dp, copied from the painter on every draw --
/// what a fling's deceleration is computed against. 1.0 until the
/// owner has drawn once, which is also the only state in which nothing
/// can be flung, since there is no content measured yet.
density: f32,
}
impl ScrollController {
pub fn new(dir: Dir, pin: Pin) -> Self {
Self {
dir,
amt: 0.0,
pending: 0.0,
travel: Travel::UNBOUNDED,
pinned_to_end: pin.pinned_to_end(dir),
gesture: DragGesture::on(dir.axis),
fling: Flinger::new(),
density: 1.0,
}
}
/// Which way this area pans.
pub fn axis(&self) -> Axis {
self.dir.axis
}
/// Which way this area's content runs -- the axis it pans along and
/// the sign it grows in. What resolves a [`Pin`].
pub fn dir(&self) -> Dir {
self.dir
}
/// How far the content has been pulled past the container's leading
/// edge -- see the field for what that means for each kind of owner.
pub fn amt(&self) -> f32 {
self.amt
}
/// Pan by `amt`, in the finger's direction: **positive scrolls up or
/// left**, moving the content the positive way along the axis. One
/// convention, everywhere, and a screen direction rather than a
/// logical one so that it means the same thing to a widget laid out
/// backwards (Iris, 2026-09-08).
///
/// Banked rather than applied: where this area can actually go is a
/// question only its owner's layout can answer, and the owner's `draw`
/// is where that answer exists.
pub fn scroll(&mut self, amt: f32) {
self.pending += amt;
}
/// What has been asked for since the last layout, clamped to the
/// travel that layout reported. Called once at the top of the owner's
/// `draw`.
///
/// **Clipping it stops a fling**, because a fling that keeps spending
/// its distance on content that is not there is what left a hard flick
/// parked a whole screen past the first row of the bench fixture
/// (docs/IRIS_TODO.md, 2026-09-07). This catches the wall the owner
/// could already see; [`Self::set_travel`] catches the one it finds by
/// walking.
pub fn take_delta(&mut self) -> f32 {
let asked = std::mem::take(&mut self.pending);
let limit = self.travel.toward(asked);
let taken = asked.clamp(-limit, limit);
if taken != asked {
self.fling.stop();
}
taken
}
/// Record content this area really moved, and by how much, in a
/// delta's own sign. For an owner that cannot state an absolute
/// position -- a lazy layout, whose origin moves as rows are paged in
/// above it.
pub fn moved_by(&mut self, delta: f32) {
self.amt -= delta;
}
/// Set where this area is outright, for an owner that knows: a
/// `ScrollArea` has measured its content and clamps against its real
/// length, and a jump to an end is a position rather than travel.
pub fn set_amt(&mut self, amt: f32) {
self.amt = amt;
}
/// Publish how far this area can still go, from the layout that just
/// ran. Stops a fling with nothing left in the direction it is
/// travelling -- the wall a lazy layout only finds by walking to it,
/// reported in the same frame that found it.
pub fn set_travel(&mut self, travel: Travel) {
self.travel = travel;
if let Some(v) = self.fling.velocity()
&& travel.toward(v) <= 0.0
{
self.fling.stop();
}
}
/// What the last layout said was left. Read by an owner that has to
/// reconcile its own walls with what it was allowed to take.
pub fn travel(&self) -> Travel {
self.travel
}
/// Whether this area is flush against the end of its content, so that
/// an appended row should bring the view with it. The owner recomputes
/// this at the end of every layout; a caller may set it to re-pin (a
/// "jump to latest" button) or to let go.
pub fn pinned_to_end(&self) -> bool {
self.pinned_to_end
}
pub fn set_pinned_to_end(&mut self, pinned: bool) {
self.pinned_to_end = pinned;
}
/// Physical pixels per dp, which a fling's deceleration is computed
/// against. Learned from the frame rather than passed in: it is a
/// physical quantity, and the owner's `draw` is where it meets the
/// only thing that knows it.
pub fn set_density(&mut self, density: f32) {
self.density = density;
}
/// Start a fling at `velocity`, in [`Self::scroll`]'s direction
/// convention. Answers whether one actually started, which is the
/// caller's cue to register the widget for frames (`UiData::animate`).
/// Cancels any fling already in progress.
///
/// **Sets the fling; it does not drive it.** A fling moves only while
/// something calls [`Self::tick`] once per frame, and what does that
/// in a running app is `UiData::tick_animations`, over the ids
/// `UiData::animate` was given. Split that way because the two halves
/// have different owners: the velocity is this area's business and
/// whether anything animates at all is the frame loop's. Missing the
/// second call is what a finger fling did on Iris's phone for two
/// builds -- the velocity was right and nothing ever advanced it,
/// which looks exactly like a list that stops dead under the finger.
///
/// The density handed on is this area's own, taken from the painter,
/// not `1.0`: it does **not** cancel out of the spline, and a
/// hardcoded 1.0 against a 2.75-density screen made a flick that
/// should coast for about a second run for 45.
pub fn fling(&mut self, velocity: f32) -> bool {
self.fling.start(velocity, self.density)
}
/// Cancel any fling in progress with no further movement -- the next
/// touch-down's job, since `AndroidFlingSpline`'s curve has no idea a
/// finger came back down and Android's own `Scroller` relies on the
/// view calling `abortAnimation` for the same reason.
pub fn cancel_fling(&mut self) {
self.fling.stop();
}
/// Whether a fling is coasting here right now. What a caller polls to
/// know whether this area is moving on its own (a test, and
/// [`PressState::scrolling`]'s own condition).
pub fn is_scrolling(&self) -> bool {
self.fling.is_flinging()
}
/// The velocity a fling in progress is coasting at, `None` when
/// nothing is flinging -- what a release's decision looks like from
/// the outside, so a test can read what the gesture measured rather
/// than re-timing the gesture itself.
pub fn fling_velocity(&self) -> Option<f32> {
self.fling.velocity()
}
/// Advance a fling by one frame, banking the distance it covered.
/// Answers whether it is still going, which is what
/// `UiData::tick_animations` reads to decide whether to keep the
/// widget registered -- so an owner's `Widget::tick` is this one line.
///
/// Stopping at a wall is [`Self::take_delta`]'s and
/// [`Self::set_travel`]'s, not this method's: both know where the
/// content ends and this one does not.
pub fn tick(&mut self, now: Instant) -> bool {
let delta = self.fling.tick(now);
self.scroll(delta);
self.fling.is_flinging()
}
/// Feed one frame of a touch gesture over this area through.
/// Registered by `WidgetLike::scrollable`; a caller with an arbiter of
/// its own drives `DragGesture` itself and hands the committed pans
/// here instead (`transcript_ui::Selection`).
///
/// `id` is the owning widget's id, which `DragGesture` takes pointer
/// capture on once the gesture commits -- so the rest of the drag
/// reaches here even after the finger has left the area, and, just as
/// importantly, stops reaching whatever is *inside* it. That is what
/// resolves a vertical drag over a focused text field: the field sees
/// the first few frames, `iris::attr`'s `on_press` gives up its
/// pending selection the moment they pass `DRAG_SLOP` vertically, and
/// this takes the gesture over. Android's own `EditText` behaves the
/// same way -- a vertical drag scrolls, and only a long press selects.
///
/// Answers whether this frame *started a fling*, which is the caller's
/// cue to register the widget for frames (`UiData::animate`) -- see
/// [`Self::fling`].
pub fn drag(
&mut self,
pointer: &PointerRequests,
id: WidgetId,
sense: CursorSense,
pos_window: Vec2,
now: Instant,
) -> bool {
// A scroll area has no selection of its own to extend, so a drag
// across the axis stays `Undecided` and one along it past the slop
// pans, which is the whole contract here.
//
// `scrolling` is the other half: a finger put down on content that
// is still coasting means "stop it here", and commits to a pan on
// that very sample with no slop to wait out
// (`DragArbiter::press_start`). The fling is cancelled in the same
// breath, since the curve has no idea a finger came back down.
let mut press = PressState::default();
if self.gesture.starts_press(sense) {
press.scrolling = self.fling.is_flinging();
self.fling.stop();
}
match self
.gesture
.handle(pointer, id, sense, pos_window, now, press)
{
// The content follows the finger, and the same `dy` an
// arbiter of the caller's own (`Selection::drag`) hands
// straight to `scroll`.
GestureOutcome::Pan(dy) => self.scroll(dy),
// Same sign as `Pan`, since `tick` applies it through the same
// `scroll`.
GestureOutcome::Released(Some(v)) => return self.fling(v),
GestureOutcome::Undecided
| GestureOutcome::Tapped
| GestureOutcome::SelectStart
| GestureOutcome::SelectExtend
| GestureOutcome::Cancelled
| GestureOutcome::Released(None) => {}
}
false
}
}
/// A widget that scrolls its own content. Implementors hand back the
/// [`ScrollController`] they own and get everything a caller does with a
/// scroll position for free.
///
/// The two implementors are [`ScrollArea`](super::ScrollArea) and
/// [`LazySpan`](super::LazySpan). What distinguishes them is only *how*
/// they spend a delta, which is their `draw`'s business -- so a caller
/// that pans, flings, reads `amt` or re-pins works through this trait and
/// never has to know which it is holding.
pub trait Scrollable {
fn controller(&self) -> &ScrollController;
fn controller_mut(&mut self) -> &mut ScrollController;
/// Pan by `amt` -- positive scrolls up or left. See
/// [`ScrollController::scroll`].
fn scroll(&mut self, amt: f32) {
self.controller_mut().scroll(amt);
}
/// See [`ScrollController::fling`], including why starting one is not
/// the same as driving it.
fn fling(&mut self, velocity: f32) -> bool {
self.controller_mut().fling(velocity)
}
fn cancel_fling(&mut self) {
self.controller_mut().cancel_fling();
}
/// See [`ScrollController::drag`].
fn drag(
&mut self,
pointer: &PointerRequests,
id: WidgetId,
sense: CursorSense,
pos_window: Vec2,
now: Instant,
) -> bool {
self.controller_mut()
.drag(pointer, id, sense, pos_window, now)
}
/// See [`ScrollController::amt`] for what this counts, which differs
/// between the two implementors in origin though not in direction.
fn amt(&self) -> f32 {
self.controller().amt()
}
fn axis(&self) -> Axis {
self.controller().axis()
}
fn is_scrolling(&self) -> bool {
self.controller().is_scrolling()
}
fn fling_velocity(&self) -> Option<f32> {
self.controller().fling_velocity()
}
fn pinned_to_end(&self) -> bool {
self.controller().pinned_to_end()
}
fn set_pinned_to_end(&mut self, pinned: bool) {
self.controller_mut().set_pinned_to_end(pinned);
}
/// Advance a fling by one frame -- an implementor's `Widget::tick` is
/// this, and nothing else animates in a scroll area.
fn tick_fling(&mut self, now: Instant) -> bool {
self.controller_mut().tick(now)
}
}
/// Register the two inputs of a scroll -- the wheel and a finger drag --
/// on a widget that owns a [`ScrollController`], and hand back the id.
///
/// The one place either is wired, shared by `WidgetLike::scrollable` and
/// `LazySpan::scrollable`: what differs between those two is only whether
/// there is a `ScrollArea` in the way, and a drag registered twice is a
/// gesture arbitrated twice.
pub fn scroll_senses<Rsc, Tag, W, WL>(w: WL, axis: Axis) -> impl WidgetIdFn<Rsc, W>
where
Rsc: HasEvents,
W: Widget + Scrollable,
WL: WidgetLike<Rsc, Tag, Widget = W>,
{
w.on(CursorSense::Scroll, move |ctx, rsc| {
let delta = ctx.data.scroll_delta.axis(axis) * 50.0;
ctx.widget(rsc).scroll(delta);
})
.on(CursorSense::drag_senses(), |ctx, rsc: &mut Rsc| {
let id = ctx.widget.id();
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
let flung = ctx
.widget(rsc)
.drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time);
// The half that actually makes it move -- a fling is set by the
// widget and driven by the frame loop, and only this side can
// reach the loop. Only when one actually started: registering a
// widget that is not animating asks the next frame to find that
// out.
if flung {
rsc.ui_mut().animate(id);
}
})
}
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more