Prune commentary and stale Rust port notes
This commit is contained in:
1 parent
3ae034a47b
commit
1e6d3b1edd
84 files changed
+334
-5648
No files matched your search
+8
-82
@@ -3,8 +3,6 @@ name = "iris"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
iris-core = { workspace = true }
|
||||
iris-macro = { workspace = true }
|
||||
@@ -15,83 +13,32 @@ wgpu = { workspace = true }
|
||||
image = { workspace = true }
|
||||
accesskit = { workspace = true }
|
||||
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.
|
||||
# The embedding app installs the logger.
|
||||
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.
|
||||
# winit's Android backend conflicts with android-view, which owns that platform here.
|
||||
[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.
|
||||
# Advancing this measured revision requires rechecking rendering, IME, and detach.
|
||||
[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.
|
||||
# 0.8.0 still aborts on detach; `view.rs::raise_if_enabled` mitigates it.
|
||||
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
|
||||
# Forces GL on Vulkan-capable hosts for comparisons. The emulator already falls back
|
||||
# to hardware GLES; enabling this there would make its build unlike the phone's.
|
||||
force-gles = []
|
||||
|
||||
[dev-dependencies]
|
||||
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
|
||||
@@ -108,32 +55,11 @@ members = [
|
||||
version = "0.1.0"
|
||||
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
|
||||
# Full DWARF once produced 54 GB of writes and an 88 GB target because every test
|
||||
# statically links the renderer stack. Use `RUSTFLAGS="-C debuginfo=2"` when needed.
|
||||
[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"
|
||||
|
||||
|
||||
@@ -31,9 +31,6 @@ 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
|
||||
@@ -54,7 +51,6 @@ def spline_positions():
|
||||
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
|
||||
@@ -62,7 +58,6 @@ def spline_positions():
|
||||
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
|
||||
|
||||
@@ -1,75 +1,6 @@
|
||||
//! 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,
|
||||
}
|
||||
@@ -83,18 +14,12 @@ impl UiRsc for BenchRsc {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -113,9 +38,6 @@ fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -127,9 +49,6 @@ fn build_message_list(
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -140,7 +59,6 @@ fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64,
|
||||
);
|
||||
}
|
||||
|
||||
/// (a) First-frame cost of a message list of N rows.
|
||||
fn bench_first_frame(n: usize) {
|
||||
let mut rsc = BenchRsc {
|
||||
ui: UiData::default(),
|
||||
@@ -162,10 +80,6 @@ fn bench_first_frame(n: usize) {
|
||||
);
|
||||
}
|
||||
|
||||
/// (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(),
|
||||
@@ -205,14 +119,6 @@ fn bench_scroll(n: usize, ticks: usize) {
|
||||
);
|
||||
}
|
||||
|
||||
/// (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(),
|
||||
@@ -276,13 +182,6 @@ fn bench_input_grows(n: usize, lines: usize) {
|
||||
);
|
||||
}
|
||||
|
||||
/// (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(),
|
||||
@@ -300,9 +199,6 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
|
||||
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
|
||||
@@ -333,21 +229,11 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) {
|
||||
);
|
||||
}
|
||||
|
||||
/// (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 {
|
||||
@@ -415,23 +301,10 @@ fn bench_expand_holds_edge(n: usize, growths: usize) {
|
||||
);
|
||||
}
|
||||
|
||||
/// (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();
|
||||
@@ -448,8 +321,6 @@ fn bench_redraw_big_text(chars: usize, redraws: usize) {
|
||||
|
||||
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);
|
||||
|
||||
@@ -38,8 +38,6 @@ 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.]+)")
|
||||
|
||||
|
||||
|
||||
@@ -85,7 +85,6 @@ 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.
|
||||
@@ -103,7 +102,6 @@ def poly_fit_least_squares(x, y, sample_count, 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
|
||||
@@ -219,7 +217,6 @@ def average(samples):
|
||||
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
|
||||
@@ -243,19 +240,12 @@ ACCELERATING_FLICK = [(0, 0.0), (10, 2.0), (20, 6.0), (30, 14.0), (40, 30.0), (5
|
||||
# (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
|
||||
|
||||
|
||||
|
||||
+1
-5
@@ -5,11 +5,7 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
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.
|
||||
# Keeps renderer creation synchronous while retrieving wgpu's async error scope.
|
||||
pollster = { workspace = true }
|
||||
bytemuck ={ workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
@@ -25,8 +25,6 @@
|
||||
# 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
|
||||
|
||||
@@ -79,7 +79,6 @@ type EventData<Rsc, E> = (E, Rc<dyn for<'a> EventFn<Rsc, <E as Event>::Data<'a>>
|
||||
pub struct TypeEventManager<Rsc: HasEvents, E: Event> {
|
||||
// TODO: reduce visiblity!!
|
||||
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>>>,
|
||||
}
|
||||
|
||||
@@ -9,19 +9,6 @@ pub use rsc::*;
|
||||
pub trait Event: Sized + 'static + Clone {
|
||||
type Data<'a>: Clone = ();
|
||||
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)]
|
||||
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
|
||||
|
||||
@@ -1,39 +1,5 @@
|
||||
//! 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 (decided
|
||||
//! 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}";
|
||||
@@ -88,6 +88,10 @@ impl RegionAlign {
|
||||
pub const fn rel(&self) -> Vec2 {
|
||||
vec2(self.x.rel(), self.y.rel())
|
||||
}
|
||||
|
||||
pub const fn pos(self) -> UiVec2 {
|
||||
UiVec2::from(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl UiVec2 {
|
||||
@@ -192,9 +196,3 @@ const impl From<RegionAlign> for UiVec2 {
|
||||
Self::rel(align.rel())
|
||||
}
|
||||
}
|
||||
|
||||
impl RegionAlign {
|
||||
pub const fn pos(self) -> UiVec2 {
|
||||
UiVec2::from(self)
|
||||
}
|
||||
}
|
||||
@@ -15,24 +15,6 @@ pub struct Len {
|
||||
/// the two are kept separate rather than one field a caller has to
|
||||
/// remember to pre-multiply.
|
||||
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 rest: f32,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::marker::Destruct;
|
||||
|
||||
/// stored in linear for sane manipulation
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Hash, PartialEq, Eq, bytemuck::Zeroable, Debug)]
|
||||
pub struct Color<T> {
|
||||
|
||||
@@ -14,19 +14,13 @@ struct LayerNode<T> {
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Ptr {
|
||||
/// continue on same level
|
||||
Next(usize),
|
||||
/// go back to parent
|
||||
Parent(usize),
|
||||
/// end
|
||||
None,
|
||||
}
|
||||
|
||||
/// TODO: currently this does not ever free layers
|
||||
/// is that realistically desired?
|
||||
pub struct Layers<T> {
|
||||
vec: Vec<LayerNode<T>>,
|
||||
/// index of last layer at top level (start at first = 0)
|
||||
last: usize,
|
||||
}
|
||||
|
||||
@@ -36,9 +30,6 @@ struct Child {
|
||||
tail: usize,
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
|
||||
+244
-409
@@ -12,40 +12,13 @@ use swash::{
|
||||
zeno::{Format, Vector},
|
||||
};
|
||||
|
||||
/// The icon font iris ships: the Nerd Fonts Symbols **Mono** subset built
|
||||
/// by `iris/core/build-icon-font.sh`, holding only the codepoints
|
||||
/// `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
|
||||
/// (decided 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>,
|
||||
@@ -57,8 +30,6 @@ pub struct FontDiagnostics {
|
||||
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 font_cx: FontContext,
|
||||
pub layout_cx: LayoutContext<UiColor>,
|
||||
@@ -84,19 +55,6 @@ pub struct 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 the 2026-09-07 decision 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 {
|
||||
let mut font_cx = FontContext::new();
|
||||
patch_android_monospace(&mut font_cx);
|
||||
@@ -112,16 +70,6 @@ impl Default for TextData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -133,24 +81,6 @@ fn register_icon_font(font_cx: &mut FontContext) -> Option<String> {
|
||||
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 the 2026-09-07 decision,
|
||||
/// "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
|
||||
@@ -204,13 +134,6 @@ fn patch_android_monospace(font_cx: &mut FontContext) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
@@ -231,9 +154,6 @@ fn android_monospace_font_filename() -> Option<String> {
|
||||
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.
|
||||
@@ -247,9 +167,6 @@ impl TextData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -268,11 +185,6 @@ impl TextData {
|
||||
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;
|
||||
@@ -327,285 +239,7 @@ impl TextData {
|
||||
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 color: UiColor,
|
||||
pub font_size: f32,
|
||||
pub line_height: f32,
|
||||
pub family: Family,
|
||||
pub wrap: bool,
|
||||
/// inner alignment of text region (within where it's drawn)
|
||||
pub align: RegionAlign,
|
||||
}
|
||||
|
||||
pub const LINE_HEIGHT_MULT: f32 = 1.1;
|
||||
|
||||
impl Default for TextAttrs {
|
||||
fn default() -> Self {
|
||||
let size = 16.0;
|
||||
Self {
|
||||
color: UiColor::WHITE,
|
||||
font_size: size,
|
||||
line_height: size * LINE_HEIGHT_MULT,
|
||||
family: Family::SansSerif,
|
||||
wrap: false,
|
||||
align: Align::CENTER_LEFT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// 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() {
|
||||
@@ -622,8 +256,6 @@ impl TextData {
|
||||
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() {
|
||||
@@ -676,41 +308,7 @@ impl TextData {
|
||||
}
|
||||
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,
|
||||
buffer: &mut TextBuffer,
|
||||
@@ -730,16 +328,255 @@ impl TextData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
Self::Icons => FontFamilyName::Generic(GenericFamily::SansSerif),
|
||||
Self::Named(name) => FontFamilyName::Named(name.as_str().into()),
|
||||
};
|
||||
FontFamily::Single(name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct SpanStyle {
|
||||
pub range: Range<usize>,
|
||||
pub color: Option<UiColor>,
|
||||
pub family: Option<Family>,
|
||||
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 color: UiColor,
|
||||
pub font_size: f32,
|
||||
pub line_height: f32,
|
||||
pub family: Family,
|
||||
pub wrap: bool,
|
||||
pub align: RegionAlign,
|
||||
}
|
||||
|
||||
pub const LINE_HEIGHT_MULT: f32 = 1.1;
|
||||
|
||||
impl Default for TextAttrs {
|
||||
fn default() -> Self {
|
||||
let size = 16.0;
|
||||
Self {
|
||||
color: UiColor::WHITE,
|
||||
font_size: size,
|
||||
line_height: size * LINE_HEIGHT_MULT,
|
||||
family: Family::SansSerif,
|
||||
wrap: false,
|
||||
align: Align::CENTER_LEFT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_coords(coords: &[i16]) -> u64 {
|
||||
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
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::icon;
|
||||
|
||||
/// 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");
|
||||
@@ -762,8 +599,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The font registers, so `Family::Icons` resolves to a real family
|
||||
/// rather than falling through to sans-serif and drawing tofu.
|
||||
#[test]
|
||||
fn the_icon_family_registers_and_resolves() {
|
||||
let data = TextData::default();
|
||||
|
||||
@@ -22,10 +22,6 @@ pub enum TextureKind {
|
||||
},
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -46,8 +42,6 @@ pub struct TextureHandle {
|
||||
pub struct Textures {
|
||||
free: Vec<u32>,
|
||||
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
|
||||
@@ -119,8 +113,6 @@ impl Textures {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -152,18 +144,6 @@ impl Textures {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -180,21 +160,16 @@ impl Textures {
|
||||
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
|
||||
@@ -208,15 +183,6 @@ impl Textures {
|
||||
/// 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
|
||||
@@ -275,8 +241,6 @@ impl TextureHandle {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -320,9 +284,6 @@ mod tests {
|
||||
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();
|
||||
@@ -341,9 +302,6 @@ mod tests {
|
||||
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();
|
||||
@@ -357,9 +315,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -373,7 +328,6 @@ mod tests {
|
||||
);
|
||||
drop(dropped);
|
||||
textures.free();
|
||||
// Drain the updates so far, the way a frame does.
|
||||
assert!(textures.updates().count() > 0);
|
||||
|
||||
textures.reupload();
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
//! 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},
|
||||
@@ -16,30 +5,16 @@ use crate::{
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -47,13 +22,11 @@ pub struct GlyphKey {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -71,12 +44,7 @@ struct Page {
|
||||
#[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>>,
|
||||
}
|
||||
|
||||
@@ -138,7 +106,6 @@ impl GlyphAtlas {
|
||||
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;
|
||||
@@ -165,14 +132,10 @@ impl GlyphAtlas {
|
||||
(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
|
||||
}
|
||||
@@ -185,30 +148,6 @@ impl GlyphAtlas {
|
||||
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();
|
||||
@@ -217,15 +156,10 @@ impl GlyphAtlas {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -253,10 +187,6 @@ fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) {
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -268,14 +198,6 @@ fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -8,15 +8,6 @@ pub struct WindowUniform {
|
||||
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)]
|
||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct PrimitiveInstance {
|
||||
@@ -27,11 +18,6 @@ pub struct PrimitiveInstance {
|
||||
pub move_idx: MoveIdx,
|
||||
}
|
||||
|
||||
/// The vertex layout of a layer's draw order: one `u32` slot into the
|
||||
/// global instance arena per instance, stepped per instance. Everything a
|
||||
/// primitive is made of used to be here as eight vertex attributes; it
|
||||
/// moved into the storage buffer above so the fragment stage can read it
|
||||
/// too.
|
||||
pub fn instance_slot_layout() -> VertexBufferLayout<'static> {
|
||||
const ATTRIBS: [VertexAttribute; 1] = vertex_attr_array![0 => Uint32];
|
||||
VertexBufferLayout {
|
||||
@@ -49,38 +35,9 @@ impl MaskIdx {
|
||||
|
||||
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)]
|
||||
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct Mask {
|
||||
/// 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
|
||||
@@ -90,20 +47,9 @@ pub struct Mask {
|
||||
/// 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 --
|
||||
|
||||
@@ -9,58 +9,20 @@ use std::time::{Duration, Instant};
|
||||
/// "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;
|
||||
|
||||
/// How many measurable frame-to-frame gaps [`FrameReport::
|
||||
/// sustained_frame_hz`] needs before it will answer at all. A tenth of a
|
||||
/// second's worth at any plausible rate -- enough for a rate to mean
|
||||
/// something, and little enough that any real phase has it.
|
||||
const MIN_CADENCE_SAMPLES: usize = 12;
|
||||
|
||||
/// 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,
|
||||
/// Frames whose **work** exceeded the budget -- `total` minus the
|
||||
/// swapchain wait, since a frame held back by the display was ready
|
||||
/// on time and the display was not.
|
||||
///
|
||||
/// Judging the total instead is what this did until 2026-09-09, and
|
||||
/// it does not survive the app being *well* paced: a loop that draws
|
||||
/// in 0.4ms and then waits its turn measures one whole refresh period
|
||||
/// per frame, so every frame sits exactly on the budget and `late`
|
||||
/// becomes a coin toss on noise. See [`Self::missed`] for the
|
||||
/// question "did a frame fail to arrive", which is the one a reader
|
||||
/// actually sees.
|
||||
///
|
||||
/// On a backend that blocks in `present()` rather than in the
|
||||
/// acquire -- GLES, and so this repo's emulator -- the wait lands in
|
||||
/// `submit` instead and this over-counts. Named rather than
|
||||
@@ -81,25 +43,10 @@ pub struct PhaseStats {
|
||||
/// say that.
|
||||
/// Vsyncs that went by with no frame produced for them, counted from
|
||||
/// the gap between consecutive frames rather than from their cost.
|
||||
///
|
||||
/// **`late` and this are different questions and the second is the
|
||||
/// one a reader sees.** A frame can be over budget and still be shown
|
||||
/// on the next vsync; a frame that is never produced leaves the
|
||||
/// previous one on screen for two refreshes, which is the stutter.
|
||||
/// Nothing in a report could say this before 2026-09-09 -- the two
|
||||
/// were folded together under `late`, so "we drew every frame, some
|
||||
/// slowly" and "we skipped 1 frame in 8" read identically.
|
||||
///
|
||||
/// Zero on the first frame of a run, whose gap is unknowable.
|
||||
pub missed: u64,
|
||||
pub build_p50: Duration,
|
||||
pub acquire_p50: Duration,
|
||||
pub submit_p50: 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,
|
||||
}
|
||||
|
||||
@@ -142,29 +89,10 @@ impl std::fmt::Display for PhaseStats {
|
||||
|
||||
/// The parts one frame's wall time divides into, measured rather than
|
||||
/// inferred: what a caller hands [`FrameReport::record`].
|
||||
///
|
||||
/// The three are consecutive and together they are `total`, so whatever
|
||||
/// is left after `acquire` and `submit` is the frame's own work -- laying
|
||||
/// out, shaping text, building primitives and recording the render pass.
|
||||
/// That leftover is what a report calls `build`.
|
||||
///
|
||||
/// **`acquire` is the one that is not work.** It is the wait inside
|
||||
/// `Surface::get_current_texture` for a swapchain image to come free,
|
||||
/// which is the display pacing the app: an app that draws faster than the
|
||||
/// screen refreshes spends *most* of every frame there, and that is the
|
||||
/// healthy state rather than a slow one. It was inside the CPU half until
|
||||
/// 2026-09-09, which made a fling's frames read as several milliseconds
|
||||
/// of iris being slow when they were milliseconds of iris waiting its
|
||||
/// turn -- UI_RULES.md's rule against presenting an inferred value as a
|
||||
/// measured one, arriving in a diagnostic.
|
||||
#[derive(Clone, Copy, Default, Debug)]
|
||||
pub struct FrameParts {
|
||||
/// Redraw start to after `present()` was called -- the span the whole
|
||||
/// report is about.
|
||||
pub total: Duration,
|
||||
/// The wait for a swapchain image (`get_current_texture`).
|
||||
pub acquire: Duration,
|
||||
/// `queue.submit` plus `present()`.
|
||||
pub submit: Duration,
|
||||
}
|
||||
|
||||
@@ -201,24 +129,11 @@ impl FrameParts {
|
||||
.saturating_sub(self.submit)
|
||||
}
|
||||
|
||||
/// Everything that was not waiting for the display's permission to
|
||||
/// draw -- `build` plus `submit`. What a frame had to finish before
|
||||
/// it could be shown, and so what a budget is meaningfully compared
|
||||
/// against; see [`PhaseStats::late`].
|
||||
pub fn work(&self) -> Duration {
|
||||
self.total.saturating_sub(self.acquire)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -228,17 +143,8 @@ impl FrameParts {
|
||||
/// [`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).
|
||||
/// See [`FrameParts`] for how the three rings divide a frame up.
|
||||
submit_ring: Box<[Duration; RING_CAPACITY]>,
|
||||
/// The `acquire` half of each sample in `ring`, same index, same
|
||||
/// lifetime -- see [`FrameParts::acquire`], which is the part that is
|
||||
@@ -247,47 +153,16 @@ pub struct FrameReport {
|
||||
/// How long before each sample the *previous* frame was, same index,
|
||||
/// same lifetime -- the frame's own cadence rather than its cost. See
|
||||
/// [`PhaseStats::missed`] for why a report needs both.
|
||||
///
|
||||
/// **`Duration::ZERO` means "no cadence information", not "no gap".**
|
||||
/// Two frames say nothing about the display's rhythm unless the app
|
||||
/// was actually trying to draw between them: the first frame after a
|
||||
/// `reset` has nothing before it, and a frame that follows an *idle*
|
||||
/// one is separated by however long nobody wanted anything drawn.
|
||||
/// Counting those was this counter's first version, and it reported
|
||||
/// a bench's own deliberate pauses as stutter -- 276 "missed" frames
|
||||
/// for sixteen 300ms rests between flings, and 2410 for twelve
|
||||
/// hundred 50ms gaps between keystrokes (Iris's phone, 2026-09-09).
|
||||
gap_ring: Box<[Duration; RING_CAPACITY]>,
|
||||
/// When the last recorded frame was and whether it had asked for
|
||||
/// another -- `None` until the first frame since a `reset`. The flag
|
||||
/// is what makes the next frame's gap a measurement rather than a
|
||||
/// record of how long the app sat idle.
|
||||
last_frame: Option<(Instant, bool)>,
|
||||
/// 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,
|
||||
@@ -295,25 +170,8 @@ pub struct FrameStats {
|
||||
pub p90: Duration,
|
||||
pub p99: Duration,
|
||||
pub worst: Duration,
|
||||
/// Median of [`FrameParts::build`] -- iris's own CPU work per frame:
|
||||
/// laying out, shaping text, building primitives and recording the
|
||||
/// render pass. 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, and corrected on 2026-09-09 to stop counting the
|
||||
/// swapchain wait below as iris's own work.
|
||||
pub cpu_p50: Duration,
|
||||
/// Median of [`FrameParts::acquire`]: the wait for a swapchain image.
|
||||
/// **Not work** -- see that field's doc. A large number here beside a
|
||||
/// small `cpu_p50` is an app comfortably ahead of the display, which
|
||||
/// is what it should look like.
|
||||
pub acquire_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,
|
||||
}
|
||||
|
||||
@@ -359,8 +217,6 @@ impl FrameReport {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one frame, split into [`FrameParts`]. O(1), no allocation.
|
||||
///
|
||||
/// One entry point rather than one per shape of measurement: a caller
|
||||
/// with nothing but a total passes `FrameParts::whole(total)`, which
|
||||
/// says so in the type instead of leaving the report to guess from a
|
||||
@@ -368,8 +224,6 @@ impl FrameReport {
|
||||
pub fn record(&mut self, at: Instant, parts: FrameParts, animating: bool) {
|
||||
self.gap_ring[self.pos] = match self.last_frame {
|
||||
Some((last, true)) => at.saturating_duration_since(last),
|
||||
// Nothing was moving, so the distance to this frame is idle
|
||||
// time rather than cadence -- see `gap_ring`'s own doc.
|
||||
Some((_, false)) | None => Duration::ZERO,
|
||||
};
|
||||
self.last_frame = Some((at, animating));
|
||||
@@ -385,12 +239,6 @@ impl FrameReport {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -400,8 +248,6 @@ impl FrameReport {
|
||||
self.phases.clear();
|
||||
}
|
||||
|
||||
/// One recorded slot's three parts, back as the type they were
|
||||
/// recorded in.
|
||||
fn parts(&self, slot: usize) -> FrameParts {
|
||||
FrameParts {
|
||||
total: self.ring[slot],
|
||||
@@ -410,16 +256,7 @@ impl FrameReport {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
// (review, 2026-09-06).
|
||||
debug_assert!(
|
||||
self.phases
|
||||
.last()
|
||||
@@ -432,12 +269,6 @@ impl FrameReport {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -478,18 +309,11 @@ impl FrameReport {
|
||||
complete,
|
||||
};
|
||||
}
|
||||
// Each part gets its own sort: medians do not distribute
|
||||
// over subtraction, so `build`'s median is not `total`'s
|
||||
// minus the other two.
|
||||
let part_p50 = |part: &dyn Fn(usize) -> Duration| {
|
||||
let mut v: Vec<Duration> = slots.iter().map(|&j| part(j)).collect();
|
||||
v.sort_unstable();
|
||||
v[v.len() / 2]
|
||||
};
|
||||
// A gap of more than one and a half budgets means at
|
||||
// least one vsync came and went unanswered; the count is
|
||||
// how many, so a frame arriving three periods late says 2.
|
||||
//
|
||||
// **The phase's own first frame is skipped**: its gap
|
||||
// reaches back into the previous phase, across whatever
|
||||
// the run did between the two -- a bench pausing a second
|
||||
@@ -532,10 +356,6 @@ impl FrameReport {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The rate frames were actually **sustained** at, in Hz, over the
|
||||
/// stretches where the app was animating -- measurable gaps divided
|
||||
/// into their own total, so idle time is excluded by construction.
|
||||
///
|
||||
/// **This is a floor on the display's refresh rate, never a reading
|
||||
/// of it.** You cannot observe a cadence faster than you draw, so an
|
||||
/// app that never keeps up says nothing about the panel; a caller
|
||||
@@ -571,9 +391,6 @@ impl FrameReport {
|
||||
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 acquire_samples: Vec<Duration> = self.acquire_ring[..self.len].to_vec();
|
||||
let cpu_samples: Vec<Duration> = (0..self.len).map(|j| self.parts(j).build()).collect();
|
||||
@@ -595,24 +412,11 @@ impl FrameReport {
|
||||
})
|
||||
}
|
||||
|
||||
/// `(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);
|
||||
// The frame's work, not its total -- the same rule and the same
|
||||
// reason as `PhaseStats::late`, which this is the run-wide half
|
||||
// of.
|
||||
let late = (0..self.len)
|
||||
.filter(|&j| self.parts(j).work() > budget)
|
||||
.count() as u64;
|
||||
@@ -654,8 +458,6 @@ mod tests {
|
||||
#[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(
|
||||
Instant::now(),
|
||||
@@ -690,8 +492,6 @@ mod tests {
|
||||
|
||||
#[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(
|
||||
@@ -713,9 +513,6 @@ mod tests {
|
||||
|
||||
#[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(
|
||||
Instant::now(),
|
||||
@@ -730,10 +527,6 @@ mod tests {
|
||||
#[test]
|
||||
fn each_part_reports_its_own_median_and_build_excludes_the_wait() {
|
||||
let mut r = FrameReport::new();
|
||||
// Three frames of the same 30ms total, with the split moving:
|
||||
// each part needs its own sort, and `build` is what is left after
|
||||
// both waits -- not the total, which is the bug this replaced
|
||||
// (the swapchain wait used to be counted as iris's own work).
|
||||
for (acquire, submit) in [(5, 2), (10, 3), (20, 4)] {
|
||||
r.record(
|
||||
Instant::now(),
|
||||
@@ -749,21 +542,15 @@ mod tests {
|
||||
assert_eq!(stats.p50, Duration::from_millis(30));
|
||||
assert_eq!(stats.acquire_p50, Duration::from_millis(10));
|
||||
assert_eq!(stats.gpu_wait_p50, Duration::from_millis(3));
|
||||
// 30-5-2=23, 30-10-3=17, 30-20-4=6 -> median 17.
|
||||
assert_eq!(stats.cpu_p50, Duration::from_millis(17));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gap_of_more_than_one_vsync_is_counted_as_a_missed_frame() {
|
||||
// Cost and cadence are separate questions: every frame here is
|
||||
// well inside its budget, so `late` is zero, and the run still
|
||||
// skipped three vsyncs -- which is what a reader sees as a
|
||||
// stutter and what nothing in a report could say before.
|
||||
let mut r = FrameReport::new();
|
||||
let base = Instant::now();
|
||||
let budget = Duration::from_nanos(16_666_667);
|
||||
r.mark_phase("fling");
|
||||
// Frames at 0, 1, 2, 4 (one skipped), 5, 8 (two skipped) budgets.
|
||||
for step in [0u32, 1, 2, 4, 5, 8] {
|
||||
r.record(
|
||||
base + budget * step,
|
||||
@@ -778,18 +565,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn an_idle_gap_is_not_a_missed_frame() {
|
||||
// What the first version of this counter got wrong on Iris's
|
||||
// phone: a bench rests 300ms between flings and types one
|
||||
// character per 50ms, and every one of those gaps was reported as
|
||||
// stutter (276 and 2410 "missed" frames, which is exactly the
|
||||
// rests). A frame that did not ask for another one is idle, and
|
||||
// the distance to whatever comes next says nothing.
|
||||
let mut r = FrameReport::new();
|
||||
let base = Instant::now();
|
||||
let budget = Duration::from_nanos(16_666_667);
|
||||
r.mark_phase("fling");
|
||||
// Two frames of real animation, then one that stops animating,
|
||||
// then a long rest before the next burst.
|
||||
r.record(base, FrameParts::whole(Duration::ZERO), true);
|
||||
r.record(base + budget, FrameParts::whole(Duration::ZERO), true);
|
||||
r.record(base + budget * 2, FrameParts::whole(Duration::ZERO), false);
|
||||
@@ -807,9 +586,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_sustained_120hz_run_measures_120_whatever_the_platform_says() {
|
||||
// Iris's phone, 2026-09-09: the display reported 60Hz for a run
|
||||
// that drew at 120, so every phase was judged against twice the
|
||||
// budget it should have been.
|
||||
let mut r = FrameReport::new();
|
||||
let base = Instant::now();
|
||||
let period = Duration::from_nanos(8_333_333);
|
||||
@@ -826,19 +602,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn an_app_that_cannot_keep_up_does_not_claim_a_faster_display() {
|
||||
// The other direction, and the one the first version of this got
|
||||
// wrong: this repo's emulator draws about 51fps on a 60Hz
|
||||
// display, and taking the fastest tenth of the gaps reported
|
||||
// 88Hz -- a budget no frame there could meet, invented out of the
|
||||
// app's best moments. A sustained rate cannot do that, which is
|
||||
// what makes a caller's `max` against the platform's own answer
|
||||
// safe in both directions.
|
||||
let mut r = FrameReport::new();
|
||||
let base = Instant::now();
|
||||
let mut at = base;
|
||||
for step in 0..120u32 {
|
||||
// Mostly slow with an occasional quick pair -- the shape that
|
||||
// fooled the percentile.
|
||||
at += if step % 10 == 0 {
|
||||
Duration::from_millis(8)
|
||||
} else {
|
||||
@@ -855,10 +622,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_frame_held_back_by_the_display_is_not_late() {
|
||||
// The signature of a well-paced loop: 0.4ms of work and the rest
|
||||
// of the refresh period spent waiting its turn. Judging the total
|
||||
// calls every one of those frames late; judging the work calls
|
||||
// none of them late, which is what they are.
|
||||
let mut r = FrameReport::new();
|
||||
let base = Instant::now();
|
||||
let period = Duration::from_nanos(8_333_333);
|
||||
@@ -881,10 +644,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_phase_does_not_inherit_the_pause_before_it() {
|
||||
// The half the fix above had no reason to touch: a bench rests
|
||||
// between phases, and that rest reaches the next phase's first
|
||||
// frame as its gap. Charging it there would open every phase with
|
||||
// a large invented `missed`.
|
||||
let mut r = FrameReport::new();
|
||||
let base = Instant::now();
|
||||
let budget = Duration::from_nanos(16_666_667);
|
||||
@@ -896,7 +655,6 @@ mod tests {
|
||||
true,
|
||||
);
|
||||
}
|
||||
// A second of rest, then the next phase starts clean.
|
||||
let after = base + Duration::from_secs(1);
|
||||
r.mark_phase("type");
|
||||
for step in [0u32, 1, 2] {
|
||||
@@ -925,11 +683,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -1011,7 +765,6 @@ mod tests {
|
||||
#[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(
|
||||
Instant::now(),
|
||||
FrameParts::whole(Duration::from_millis(10)),
|
||||
|
||||
@@ -28,42 +28,8 @@ pub use frame_report::{FrameParts, FrameReport, FrameStats, JANK_THRESHOLD};
|
||||
pub use primitive::*;
|
||||
pub use sdf::{distance_from_rect, rounded_rect_coverage};
|
||||
|
||||
/// 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,
|
||||
@@ -77,31 +43,11 @@ pub fn device_limits() -> Limits {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -131,9 +77,6 @@ impl WgpuErrorLog {
|
||||
pub struct UiRenderNode {
|
||||
uniform_group: BindGroup,
|
||||
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,
|
||||
@@ -145,28 +88,13 @@ pub struct UiRenderNode {
|
||||
active: Vec<usize>,
|
||||
window_buffer: Buffer,
|
||||
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>,
|
||||
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 {
|
||||
order: ArrBuf<u32>,
|
||||
/// A standalone image's slots, kept apart from `order` because each
|
||||
@@ -182,14 +110,7 @@ impl UiRenderNode {
|
||||
pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) {
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &self.uniform_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 {
|
||||
let layer = &self.layers[i];
|
||||
@@ -307,31 +228,12 @@ impl UiRenderNode {
|
||||
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(
|
||||
device: &Device,
|
||||
queue: &Queue,
|
||||
config: &SurfaceConfiguration,
|
||||
window_size: impl Into<Vec2>,
|
||||
) -> 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);
|
||||
@@ -341,29 +243,6 @@ impl UiRenderNode {
|
||||
source: ShaderSource::Wgsl(SHAPE_SHADER.into()),
|
||||
});
|
||||
|
||||
// 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 {
|
||||
@@ -610,12 +489,6 @@ impl UiRenderNode {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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: &[
|
||||
@@ -685,20 +558,10 @@ impl UiRenderNode {
|
||||
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()
|
||||
}
|
||||
|
||||
/// Atlas-array `grow_array` calls since the last call -- same calling
|
||||
/// convention as `take_image_bind_group_creates` (call once per frame,
|
||||
/// 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
|
||||
/// landing on the same frame the glyphs vanished, that is the
|
||||
/// coincidence to chase first.
|
||||
pub fn take_atlas_pages_grown(&mut self) -> u64 {
|
||||
self.textures.take_pages_grown()
|
||||
}
|
||||
|
||||
@@ -21,9 +21,6 @@ pub const IMAGE_BINDING: u32 = 1;
|
||||
pub trait Primitive: Pod {
|
||||
const BINDING: u32;
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -63,13 +60,6 @@ macro_rules! primitives {
|
||||
|
||||
impl PrimitiveBuffers {
|
||||
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] {
|
||||
[
|
||||
@@ -126,59 +116,14 @@ macro_rules! primitives {
|
||||
(@count $t:tt) => { 1 };
|
||||
}
|
||||
|
||||
/// Every primitive instance in the tree, in one arena that all layers
|
||||
/// share, plus the per-primitive data (`rects`, `glyphs`) they index.
|
||||
///
|
||||
/// **Why one arena rather than one per layer**, which is what this was:
|
||||
/// 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>,
|
||||
/// The value a slot held before its first rewrite since the last
|
||||
/// upload. Layout may place a widget provisionally and restore it in
|
||||
/// the same frame; remembering the pre-frame value lets `set_instance`
|
||||
/// clear that dirty bit instead of uploading a change the GPU never
|
||||
/// needs to observe. Entries are overwritten on the next clean-to-dirty
|
||||
/// transition, so no separate end-of-frame sweep is needed.
|
||||
original_instances: Vec<Option<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,
|
||||
/// Which instance slots have changed since the last upload. Was a
|
||||
/// single `bool` covering the instances **and** the per-primitive
|
||||
/// data until 2026-09-09, so rewriting one rect's region re-uploaded
|
||||
/// every glyph as well; each array carries its own now.
|
||||
pub dirty: Dirty,
|
||||
}
|
||||
|
||||
@@ -198,9 +143,6 @@ impl Default for Primitives {
|
||||
}
|
||||
|
||||
impl Primitives {
|
||||
/// 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
|
||||
@@ -232,10 +174,6 @@ impl Primitives {
|
||||
(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,
|
||||
@@ -312,9 +250,6 @@ impl Primitives {
|
||||
);
|
||||
}
|
||||
|
||||
/// The image half of [`Self::recycle`] -- no `PrimitiveData` entry, so
|
||||
/// `texture_idx` rides in `idx` exactly as [`Self::alloc_image`] puts
|
||||
/// it there.
|
||||
pub fn recycle_image(
|
||||
&mut self,
|
||||
h: &PrimitiveHandle,
|
||||
@@ -378,23 +313,14 @@ impl Primitives {
|
||||
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;
|
||||
}
|
||||
@@ -420,18 +346,10 @@ impl Primitives {
|
||||
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())
|
||||
@@ -453,8 +371,6 @@ impl Primitives {
|
||||
&self.instances
|
||||
}
|
||||
|
||||
/// The instance arena and its dirty set together -- see
|
||||
/// [`PrimitiveVec::for_upload`].
|
||||
pub fn instances_for_upload(&mut self) -> (&[PrimitiveInstance], &mut Dirty) {
|
||||
(&self.instances, &mut self.dirty)
|
||||
}
|
||||
@@ -465,8 +381,6 @@ impl Primitives {
|
||||
&mut self.data
|
||||
}
|
||||
|
||||
/// Whether anything at all needs uploading -- the instances or any of
|
||||
/// the per-primitive arrays.
|
||||
pub fn needs_upload(&self) -> bool {
|
||||
!self.dirty.is_clean() || self.data.needs_upload()
|
||||
}
|
||||
@@ -496,9 +410,6 @@ impl Primitives {
|
||||
}
|
||||
}
|
||||
|
||||
/// One layer's draw order: the slots of the global arena it draws, in the
|
||||
/// order they were written. The vertex buffer of a layer is exactly this.
|
||||
///
|
||||
/// 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.
|
||||
@@ -529,9 +440,6 @@ impl LayerOrder {
|
||||
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 {
|
||||
@@ -541,8 +449,6 @@ impl LayerOrder {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -559,8 +465,6 @@ impl LayerOrder {
|
||||
changes
|
||||
}
|
||||
|
||||
/// The draw order and its dirty set together -- see
|
||||
/// [`PrimitiveVec::for_upload`].
|
||||
pub fn order_for_upload(&mut self) -> (&[u32], &mut Dirty) {
|
||||
(&self.order, &mut self.order_dirty)
|
||||
}
|
||||
@@ -575,8 +479,6 @@ impl LayerOrder {
|
||||
dirty: &mut Dirty,
|
||||
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| {
|
||||
@@ -584,9 +486,6 @@ impl LayerOrder {
|
||||
if pos == list.len() {
|
||||
return None;
|
||||
}
|
||||
// `swap_remove` moved the tail entry here; nothing else in
|
||||
// the list changed, which is why compacting an order is
|
||||
// two dirty entries rather than the whole buffer.
|
||||
dirty.mark(pos);
|
||||
Some(OrderChange {
|
||||
slot: list[pos],
|
||||
@@ -606,14 +505,8 @@ impl LayerOrder {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
@@ -628,13 +521,8 @@ pub enum Drawn {
|
||||
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)]
|
||||
pub struct PrimitiveHandle {
|
||||
pub layer: usize,
|
||||
@@ -683,11 +571,6 @@ 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)]
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct GlyphPrimitive {
|
||||
@@ -699,10 +582,6 @@ pub struct GlyphPrimitive {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -759,20 +638,6 @@ impl<T> PrimitiveVec<T> {
|
||||
self.dirty.mark(i);
|
||||
i
|
||||
}
|
||||
/// Overwrites an entry already allocated -- the recycle path
|
||||
/// ([`Primitives::recycle`]) -- and marks it dirty **only if the
|
||||
/// value actually differs**.
|
||||
///
|
||||
/// That check is not an optimisation of the comparison; it is what
|
||||
/// makes the dirty set mean "changed" rather than "written". A row
|
||||
/// that moves, or is re-laid-out at a new width, rewrites every glyph
|
||||
/// it owns with the same `uv`, `layer`, `colour` and `flags` -- what
|
||||
/// moved is the *instance's* region, which is a different array. Over
|
||||
/// the bench fixture's streamed reply the glyph array was being
|
||||
/// marked at 73% per frame against 0.6% genuinely changed, a 122x
|
||||
/// over-upload, entirely from this (`scripts/rigs/ui-profile`'s
|
||||
/// `arena_churn`, which prints both numbers side by side so the gap
|
||||
/// cannot reopen unnoticed).
|
||||
pub fn set(&mut self, i: usize, t: T)
|
||||
where
|
||||
T: Pod,
|
||||
@@ -845,8 +710,6 @@ mod tests {
|
||||
"the GPU never observes the provisional position"
|
||||
);
|
||||
|
||||
// A subsequent frame takes its baseline from the value currently in
|
||||
// the arena, rather than reusing the now-stale original above.
|
||||
primitives.set_instance(0, moved, owner);
|
||||
assert!(!primitives.dirty.is_clean());
|
||||
primitives.dirty.clear();
|
||||
|
||||
@@ -1,28 +1,7 @@
|
||||
//! 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),
|
||||
@@ -31,12 +10,6 @@ pub fn distance_from_rect(pos: Vec2, center: Vec2, corner: Vec2, radius: f32) ->
|
||||
(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;
|
||||
|
||||
+17
-69
@@ -1,7 +1,5 @@
|
||||
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.
|
||||
// Standalone images select their texture through their own bind group.
|
||||
const TEXTURE: u32 = 1u;
|
||||
const GLYPH: u32 = 2u;
|
||||
|
||||
@@ -22,24 +20,19 @@ struct Rect {
|
||||
struct GlyphInfo {
|
||||
uv_min: vec2<f32>,
|
||||
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".
|
||||
// A layer in the shared atlas array, not a bind-group index.
|
||||
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).
|
||||
/// Mirrors `Mask` in data.rs. `parent` is u32::MAX at the root.
|
||||
struct Mask {
|
||||
primitive: u32,
|
||||
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.
|
||||
/// Mirrors `MoveOffset` in data.rs.
|
||||
struct MoveOffset {
|
||||
delta: vec2<f32>,
|
||||
parent: u32,
|
||||
@@ -55,50 +48,27 @@ struct UiScalar {
|
||||
abs: f32,
|
||||
}
|
||||
|
||||
// The shared glyph atlas: every page is one layer. Growing it recreates this
|
||||
// texture with headroom and copies the old layers across -- see
|
||||
// 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).
|
||||
// One array texture avoids descriptor indexing, which is not universal on Android.
|
||||
@group(2) @binding(0)
|
||||
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.
|
||||
// Image draws bind their texture here; other draws bind a 1x1 placeholder.
|
||||
@group(2) @binding(1)
|
||||
var image_texture: texture_2d<f32>;
|
||||
@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.
|
||||
// Kept outside group 2 so standalone image bind groups need not name these buffers.
|
||||
@group(3) @binding(0)
|
||||
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.
|
||||
// Shared by the vertex stage's drawn primitive and the fragment stage's mask shape.
|
||||
@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.
|
||||
// Keep synchronized with render_state.rs. The bound prevents a malformed
|
||||
// parent cycle from hanging the GPU; real widget trees have exceeded 16.
|
||||
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;
|
||||
@@ -117,8 +87,7 @@ struct WindowUniform {
|
||||
dim: vec2<f32>,
|
||||
};
|
||||
|
||||
/// Mirrors `PrimitiveInstance` in data.rs -- the placement and what to
|
||||
/// draw there. `x`/`y` are the `UiRegion`'s two spans.
|
||||
/// Mirrors `PrimitiveInstance` in data.rs.
|
||||
struct PrimitiveInstance {
|
||||
x: UiSpan,
|
||||
y: UiSpan,
|
||||
@@ -128,7 +97,6 @@ struct PrimitiveInstance {
|
||||
move_idx: u32,
|
||||
}
|
||||
|
||||
/// A layer's draw order: one slot into `instances` per instance drawn.
|
||||
struct InstanceInput {
|
||||
@location(0) slot: u32,
|
||||
}
|
||||
@@ -137,8 +105,7 @@ struct VertexOutput {
|
||||
@location(0) top_left: vec2<f32>,
|
||||
@location(1) bot_right: vec2<f32>,
|
||||
@location(2) uv: vec2<f32>,
|
||||
// `flat` is the only interpolation an integer can have, and naga
|
||||
// (wgpu 30) now requires saying so rather than inferring it.
|
||||
// Naga requires integer varyings to declare flat interpolation.
|
||||
@location(3) @interpolate(flat) binding: u32,
|
||||
@location(4) @interpolate(flat) idx: u32,
|
||||
@location(5) @interpolate(flat) mask_idx: u32,
|
||||
@@ -152,10 +119,7 @@ struct Region {
|
||||
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.
|
||||
/// Shared by drawing and mask coverage so their geometry cannot diverge.
|
||||
struct Corners {
|
||||
top_left: vec2<f32>,
|
||||
bot_right: vec2<f32>,
|
||||
@@ -224,10 +188,7 @@ fn fs_main(
|
||||
color = vec4(1.0, 0.0, 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
// Every mask on the chain, not just the innermost: a widget that set
|
||||
// its own mask inside another is clipped by both, and the coverages
|
||||
// multiply -- so a pixel inside two feathered corners is dimmed by
|
||||
// both, which is what a compositor does (`Mask::parent` in data.rs).
|
||||
// Nested masks multiply coverage, matching the CPU hit test.
|
||||
var mask_idx = in.mask_idx;
|
||||
for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) {
|
||||
if mask_idx == 4294967295u {
|
||||
@@ -240,17 +201,11 @@ fn fs_main(
|
||||
return color;
|
||||
}
|
||||
|
||||
/// How much of `pos` one mask lets through: the referenced primitive's
|
||||
/// own coverage at that pixel, from the same SDF the primitive is drawn
|
||||
/// 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.
|
||||
/// Uses the referenced primitive itself so its drawn and clipped edges agree.
|
||||
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.
|
||||
// Painter::set_mask rejects non-rect shapes; fail open if that invariant breaks.
|
||||
return 1.0;
|
||||
}
|
||||
let c = corners_of(inst);
|
||||
@@ -272,11 +227,7 @@ fn draw_glyph(region: Region, g: GlyphInfo) -> vec4<f32> {
|
||||
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.
|
||||
/// Keep synchronized with the CPU hit-test implementation in render::sdf.
|
||||
fn rounded_rect_coverage(
|
||||
pos: vec2<f32>,
|
||||
top_left: vec2<f32>,
|
||||
@@ -309,10 +260,7 @@ fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
|
||||
}
|
||||
|
||||
fn distance_from_rect(pixel_pos: vec2<f32>, rect_center: vec2<f32>, rect_corner: vec2<f32>, radius: f32) -> f32 {
|
||||
// vec from center to pixel
|
||||
let p = pixel_pos - rect_center;
|
||||
// vec from inner rect corner to pixel
|
||||
let q = abs(p) - (rect_corner - radius);
|
||||
return length(max(q, vec2(0.0))) - radius;
|
||||
}
|
||||
|
||||
@@ -9,12 +9,7 @@ use super::atlas::PAGE;
|
||||
/// 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
|
||||
@@ -23,16 +18,12 @@ enum Slot {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -41,11 +32,6 @@ struct ImageGpu {
|
||||
/// - **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 {
|
||||
device: Device,
|
||||
queue: Queue,
|
||||
@@ -55,7 +41,6 @@ pub struct GpuTextures {
|
||||
array_texture: Texture,
|
||||
array_view: TextureView,
|
||||
array_capacity: u32,
|
||||
/// Layers actually written. Only grows -- see `Slot::Page`.
|
||||
page_count: u32,
|
||||
|
||||
sampler: Sampler,
|
||||
@@ -64,18 +49,7 @@ pub struct GpuTextures {
|
||||
/// but the layout requires something bound regardless.
|
||||
null_view: 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,
|
||||
}
|
||||
|
||||
@@ -161,7 +135,6 @@ impl GpuTextures {
|
||||
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) {
|
||||
@@ -171,9 +144,6 @@ impl GpuTextures {
|
||||
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();
|
||||
@@ -231,10 +201,6 @@ impl GpuTextures {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -275,10 +241,6 @@ impl GpuTextures {
|
||||
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 {
|
||||
@@ -332,11 +294,6 @@ impl GpuTextures {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -364,20 +321,6 @@ impl GpuTextures {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -426,15 +369,10 @@ impl GpuTextures {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and zeroes the standalone-image bind-group creation counter --
|
||||
/// call once per frame before `update()`, mirroring
|
||||
/// `UiRenderState::take_counters`.
|
||||
pub fn take_bind_group_creates(&mut self) -> u64 {
|
||||
std::mem::take(&mut self.bind_group_creates)
|
||||
}
|
||||
|
||||
/// Reads and zeroes the atlas-array-grow counter -- see `pages_grown`'s
|
||||
/// field comment.
|
||||
pub fn take_pages_grown(&mut self) -> u64 {
|
||||
std::mem::take(&mut self.pages_grown)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ use crate::util::Dirty;
|
||||
use bytemuck::Pod;
|
||||
use wgpu::*;
|
||||
|
||||
/// A GPU array whose `Buffer` outlives the data in it.
|
||||
///
|
||||
/// **The buffer has a capacity, and shrinking never reallocates.** That
|
||||
/// is not only about allocation cost: a fresh `Buffer`'s contents are
|
||||
/// undefined, so a reallocation is the one event after which a *partial*
|
||||
@@ -13,23 +11,11 @@ use wgpu::*;
|
||||
/// is therefore the precondition for uploading only what changed, and
|
||||
/// [`Self::update`] says which of the two happened so a caller can force
|
||||
/// the whole range dirty.
|
||||
///
|
||||
/// It reallocated on every length change until 2026-09-09, which made the
|
||||
/// streaming path pay a full rewrite of every arena on nearly every
|
||||
/// frame -- adding one glyph changes a length. Measured over the bench
|
||||
/// fixture's 401 streamed deltas (`scripts/rigs/ui-profile`'s
|
||||
/// `arena_churn`): the glyph buffer's *changed* bytes were 3.0% of its
|
||||
/// size, but 95% of it had to be re-uploaded anyway because the buffer
|
||||
/// underneath had just been replaced.
|
||||
pub struct ArrBuf<T: Pod> {
|
||||
label: &'static str,
|
||||
usage: BufferUsages,
|
||||
pub buffer: Buffer,
|
||||
/// Entries the caller last wrote -- what a draw call reads.
|
||||
len: usize,
|
||||
/// Entries the buffer has room for. Grows geometrically and never
|
||||
/// shrinks, so a list that oscillates in length (every frame of a
|
||||
/// fling adds and drops rows) settles on one allocation.
|
||||
capacity: usize,
|
||||
_pd: PhantomData<T>,
|
||||
}
|
||||
@@ -52,11 +38,6 @@ impl<T: Pod> ArrBuf<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grows to hold `len` entries if it does not already, answering
|
||||
/// whether that meant a new `Buffer`. Doubling rather than exact, so a
|
||||
/// buffer that grows by one entry per frame -- which is what a
|
||||
/// streamed reply does to the glyph arena -- reallocates a logarithmic
|
||||
/// number of times rather than every frame.
|
||||
pub fn reserve(&mut self, device: &Device, len: usize) -> bool {
|
||||
if len <= self.capacity {
|
||||
return false;
|
||||
@@ -76,9 +57,6 @@ impl<T: Pod> ArrBuf<T> {
|
||||
usage: BufferUsages,
|
||||
label: &'static str,
|
||||
) -> Buffer {
|
||||
// A storage binding of size 0 is a validation error, and an empty
|
||||
// arena is the ordinary state of a buffer nothing has drawn into
|
||||
// yet.
|
||||
let size = (entries.max(1) * std::mem::size_of::<T>()) as u64;
|
||||
device.create_buffer(&BufferDescriptor {
|
||||
label: Some(label),
|
||||
@@ -123,10 +101,6 @@ impl<T: Pod> ArrBuf<T> {
|
||||
reallocated
|
||||
}
|
||||
|
||||
/// How far apart two dirty runs may be and still be uploaded as one
|
||||
/// -- in entries, so a wider entry merges across fewer of them and
|
||||
/// the *byte* cost of merging is the same either way. See
|
||||
/// [`Dirty::ranges`] for the measurement behind 1 KiB.
|
||||
const MERGE_GAP: usize = 1024 / std::mem::size_of::<T>();
|
||||
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
|
||||
@@ -1,23 +1,3 @@
|
||||
//! 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};
|
||||
|
||||
@@ -57,11 +37,6 @@ fn entry_node(entry: &Entry) -> Node {
|
||||
#[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,
|
||||
}
|
||||
|
||||
@@ -128,8 +103,6 @@ impl AccessTree {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ use crate::{
|
||||
LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId, util::Vec2,
|
||||
};
|
||||
|
||||
/// important non rendering data for retained drawing
|
||||
#[derive(Debug)]
|
||||
pub struct ActiveData {
|
||||
pub id: WidgetId,
|
||||
@@ -11,23 +10,13 @@ pub struct ActiveData {
|
||||
pub textures: Vec<TextureHandle>,
|
||||
pub primitives: Vec<PrimitiveHandle>,
|
||||
pub children: Vec<WidgetId>,
|
||||
/// Direct children whose reported size this widget used during its
|
||||
/// latest draw. Dirtiness propagates across these edges before layout
|
||||
/// starts, so the resulting draw still travels only parent to child.
|
||||
pub size_dependencies: Vec<WidgetId>,
|
||||
/// The inherited mask, not `own_mask`.
|
||||
pub mask: MaskIdx,
|
||||
/// The widget's retained mask slot, or `MaskIdx::NONE`.
|
||||
pub own_mask: MaskIdx,
|
||||
pub layer: LayerId,
|
||||
/// The size recorded by the last `Widget::draw` through its painter.
|
||||
pub size: Size,
|
||||
/// Retained so descendants' parent links stay valid across redraws.
|
||||
pub move_slot: MoveIdx,
|
||||
/// The optional coordinate boundary between this widget and its direct
|
||||
/// children. Descendants retain links to it across redraws, just as they
|
||||
/// do to `move_slot`.
|
||||
pub child_move_slot: Option<MoveIdx>,
|
||||
/// The part of this widget's move delta already folded into `region`.
|
||||
pub move_applied: Vec2,
|
||||
}
|
||||
@@ -18,20 +18,7 @@ pub struct UiData {
|
||||
pub textures: Textures,
|
||||
pub text: TextData,
|
||||
pub masks: TrackedArena<Mask, u32>,
|
||||
/// One entry per widget ever drawn, plus optional child-coordinate
|
||||
/// boundaries owned by containers. Together they form the parent-linked
|
||||
/// chain `resolve_move` walks in both shader stages. A widget's ordinary
|
||||
/// entry is allocated once on its first draw and reused for every later
|
||||
/// redraw of the same id, 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>,
|
||||
}
|
||||
|
||||
@@ -46,15 +33,7 @@ impl UiData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
|
||||
@@ -18,11 +18,9 @@ pub struct Painter<'a> {
|
||||
pub(super) mask: MaskIdx,
|
||||
pub(super) move_slot: MoveIdx,
|
||||
pub(super) child_move_slot: Option<MoveIdx>,
|
||||
/// This widget's retained mask slot.
|
||||
pub(super) own_mask: MaskIdx,
|
||||
pub(super) textures: Vec<TextureHandle>,
|
||||
pub(super) primitives: Vec<PrimitiveHandle>,
|
||||
/// Previous handles, consumed in draw order and freed if left over.
|
||||
pub(super) recycle: std::iter::Peekable<std::vec::IntoIter<PrimitiveHandle>>,
|
||||
pub(super) children: Vec<WidgetId>,
|
||||
pub(super) size_dependencies: Vec<WidgetId>,
|
||||
@@ -37,16 +35,12 @@ pub struct Painter<'a> {
|
||||
pub(super) id: WidgetId,
|
||||
}
|
||||
|
||||
/// A child draw whose size has not necessarily been observed by its parent.
|
||||
/// Holding this value keeps the painter borrowed, so `.size()` can only name
|
||||
/// the child from the immediately preceding draw.
|
||||
pub struct DrawResult<'p, 'a> {
|
||||
painter: &'p mut Painter<'a>,
|
||||
child: WidgetId,
|
||||
}
|
||||
|
||||
impl DrawResult<'_, '_> {
|
||||
/// Return the child's reported size and record the layout dependency.
|
||||
pub fn size(self) -> Size {
|
||||
if !self.painter.size_dependencies.contains(&self.child) {
|
||||
self.painter.size_dependencies.push(self.child);
|
||||
@@ -56,8 +50,6 @@ impl DrawResult<'_, '_> {
|
||||
}
|
||||
|
||||
impl<'a> Painter<'a> {
|
||||
/// Record the size this widget used. Every `Widget::draw` calls this
|
||||
/// exactly once; parents observe it through [`DrawResult::size`].
|
||||
pub fn set_size(&mut self, size: Size) {
|
||||
assert!(
|
||||
self.size.replace(size).is_none(),
|
||||
@@ -69,10 +61,6 @@ impl<'a> Painter<'a> {
|
||||
self.write_primitive(primitive, region, Drawn::Yes);
|
||||
}
|
||||
|
||||
/// The next handle from the previous draw, if it can hold what is
|
||||
/// about to be written: same kind of primitive, same layer, and the
|
||||
/// same answer to "does a layer's draw order name it".
|
||||
///
|
||||
/// **Consumed strictly in order, and one mismatch ends recycling for
|
||||
/// the rest of the draw.** A widget's `draw` is a function of its own
|
||||
/// state, so a redraw writes the same sequence of primitives in the
|
||||
@@ -90,8 +78,6 @@ impl<'a> Painter<'a> {
|
||||
self.recycle.next()
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -121,13 +107,6 @@ impl<'a> Painter<'a> {
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -135,7 +114,6 @@ impl<'a> Painter<'a> {
|
||||
self.primitives.push(h);
|
||||
}
|
||||
|
||||
/// Writes a primitive to be rendered
|
||||
pub fn primitive<P: Primitive>(&mut self, primitive: P) {
|
||||
self.primitive_at(primitive, self.region)
|
||||
}
|
||||
@@ -144,18 +122,6 @@ impl<'a> Painter<'a> {
|
||||
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
|
||||
@@ -184,24 +150,12 @@ impl<'a> Painter<'a> {
|
||||
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
|
||||
// (review, 2026-09-07).
|
||||
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,
|
||||
@@ -215,9 +169,6 @@ impl<'a> Painter<'a> {
|
||||
};
|
||||
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
|
||||
@@ -226,10 +177,6 @@ impl<'a> Painter<'a> {
|
||||
*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);
|
||||
@@ -241,14 +188,10 @@ impl<'a> Painter<'a> {
|
||||
self.mask = self.own_mask;
|
||||
}
|
||||
|
||||
/// Draw a widget within this widget's region. Reading the result's size
|
||||
/// records that this widget's layout depends on the child.
|
||||
pub fn widget<'p, W: ?Sized>(&'p mut self, id: &StrongWidget<W>) -> DrawResult<'p, 'a> {
|
||||
self.widget_at(id, self.region)
|
||||
}
|
||||
|
||||
/// Draws a widget somewhere within this one.
|
||||
/// Useful for drawing child widgets in select areas.
|
||||
pub fn widget_within<'p, W: ?Sized>(
|
||||
&'p mut self,
|
||||
id: &StrongWidget<W>,
|
||||
@@ -263,11 +206,6 @@ impl<'a> Painter<'a> {
|
||||
/// Once retained, it may be updated later in a redraw (for example after
|
||||
/// measuring a changed child). All deeper descendants inherit it and the
|
||||
/// CPU hit-test walk resolves the same translation as the shader.
|
||||
///
|
||||
/// This offsets the child coordinate space, not this widget: its own
|
||||
/// primitives and hit region remain fixed. Once allocated, the boundary
|
||||
/// stays in the chain across redraws; set it to zero to return children to
|
||||
/// their unshifted positions.
|
||||
pub fn set_child_offset(&mut self, offset: Vec2) {
|
||||
let slot = match self.child_move_slot {
|
||||
Some(slot) => slot,
|
||||
@@ -322,13 +260,6 @@ impl<'a> Painter<'a> {
|
||||
region: UiRegion,
|
||||
) -> DrawResult<'p, 'a> {
|
||||
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`.
|
||||
let parent_move_slot = self.child_move_slot.unwrap_or(self.move_slot);
|
||||
self.state.draw_inner(
|
||||
self.layer,
|
||||
@@ -346,7 +277,6 @@ impl<'a> Painter<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Place an already-drawn child's used area, redrawing only if its size changes.
|
||||
pub fn place<'p, W: ?Sized>(
|
||||
&'p mut self,
|
||||
id: &StrongWidget<W>,
|
||||
@@ -436,9 +366,6 @@ impl<'a> Painter<'a> {
|
||||
self.write_image(handle.image_index(), region);
|
||||
}
|
||||
|
||||
/// A standalone image draws with its own bind group rather than sharing
|
||||
/// 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 = match self.take_recycled(IMAGE_BINDING, Drawn::Yes) {
|
||||
Some(h) => {
|
||||
@@ -474,27 +401,16 @@ impl<'a> Painter<'a> {
|
||||
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();
|
||||
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
|
||||
|
||||
+1
-252
@@ -11,27 +11,16 @@ use crate::{
|
||||
util::{HashMap, HashSet, Id, Vec2},
|
||||
};
|
||||
|
||||
/// What [`UiRenderState::update`] did on its last call -- read back by the
|
||||
/// `iris::frame` diagnostic (`iris::diagnostics::log_frame` in the `iris`
|
||||
/// crate) so a report can tell a full relayout from a frame that only
|
||||
/// redrew a handful of dirty widgets from one that drew nothing at all.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RedrawKind {
|
||||
/// Neither the root nor any widget changed -- `update` did nothing.
|
||||
None,
|
||||
/// [`UiRenderState::redraw_all`]: a new root, or a resize.
|
||||
All,
|
||||
/// [`UiRenderState::redraw_updates`]: only the widgets `needs_redraw`
|
||||
/// named.
|
||||
Updates,
|
||||
}
|
||||
|
||||
pub struct UiRenderState {
|
||||
pub active: HashMap<WidgetId, ActiveData>,
|
||||
/// Every primitive in the tree, in one arena -- see [`Primitives`] for
|
||||
/// why it is not per layer.
|
||||
pub primitives: Primitives,
|
||||
/// What each layer draws, in order: slots into `primitives`.
|
||||
pub layers: PrimitiveLayers,
|
||||
pub(super) output_size: Vec2,
|
||||
/// Physical pixels per `dp` -- see `Len::dp`'s field doc. `1.0` (an
|
||||
@@ -43,14 +32,6 @@ pub struct UiRenderState {
|
||||
|
||||
old_root: Option<WidgetId>,
|
||||
resized: bool,
|
||||
/// The widgets whose `Widget::draw` is on the stack right now -- so
|
||||
/// [`Self::redraw`] can tell "this widget needs drawing again" from
|
||||
/// "an ancestor is drawing it at this very moment", where a second
|
||||
/// draw would leave the first one's primitives behind with nothing
|
||||
/// owning them. An id is inserted immediately before `draw` is called
|
||||
/// and removed the moment it returns (both in `draw_inner`), so this
|
||||
/// is empty between frames -- asserted at the end of `update`.
|
||||
///
|
||||
/// It used to only ever be inserted into, and `redraw` removed the id
|
||||
/// *before* testing for it, which made the test constant `false`: the
|
||||
/// guard could never fire and the set grew by one entry per widget
|
||||
@@ -65,14 +46,8 @@ pub struct UiRenderState {
|
||||
draw_count: u64,
|
||||
region_mut_count: u64,
|
||||
mov_count: u64,
|
||||
/// Text layouts actually computed -- bumped by `Painter::render_text`,
|
||||
/// which `TextView::render` only reaches on a cache miss.
|
||||
pub(super) shape_count: u64,
|
||||
|
||||
/// `Instant::now()` at construction -- the zero every `iris::frame` line
|
||||
/// dates itself from, so a report's `now=` is comparable to a harness's
|
||||
/// own `t_ms` (`Harness::new` builds its `base` the same way, in the
|
||||
/// same constructor call) without either side needing the wall clock.
|
||||
epoch: Instant,
|
||||
/// How many times [`Self::update`] has run -- the `iris::frame` line's
|
||||
/// frame number. Counts every call, including one that found nothing to
|
||||
@@ -80,9 +55,6 @@ pub struct UiRenderState {
|
||||
/// was never asked to run at all (a stalled event loop), not one that
|
||||
/// ran and did nothing.
|
||||
frame_no: u64,
|
||||
/// How long the redraw phase of the last [`Self::update`] took --
|
||||
/// [`Self::redraw_all`] or [`Self::redraw_updates`], whichever ran, or
|
||||
/// zero if neither did. Read back by `iris::diagnostics::log_frame`.
|
||||
last_layout: Duration,
|
||||
last_redraw_kind: RedrawKind,
|
||||
/// When the sensor dispatch (`SensorUi::run_sensors`, in the `iris`
|
||||
@@ -94,7 +66,6 @@ pub struct UiRenderState {
|
||||
last_input_at: Mutex<Option<Instant>>,
|
||||
}
|
||||
|
||||
/// State retained while replacing one draw with another.
|
||||
pub(crate) struct Retained {
|
||||
pub region: Option<UiRegion>,
|
||||
pub children: Vec<WidgetId>,
|
||||
@@ -117,21 +88,6 @@ impl Default for Retained {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bound on the parent walk -- see `resolve_move` in shader.wgsl,
|
||||
/// which walks the identical chain and must be kept in step with this
|
||||
/// constant. It exists so a cyclic `parent` link cannot hang either walk,
|
||||
/// not as a statement about how deep a real tree gets: it was 16, and the
|
||||
/// transcript screen's composer field turned out to sit **17** slots below
|
||||
/// the root (measured 2026-09-07 on this checkout's emulator, by tapping
|
||||
/// the composer in a debug build -- the assert in `resolve_move_chain`
|
||||
/// prints the chain). A chain past the bound is not reported anywhere at
|
||||
/// run time; both walks just stop summing, so the widget is drawn and hit
|
||||
/// tested short by whatever the outer slots held.
|
||||
///
|
||||
/// Named for the walk rather than for one of its two subjects: it bounds
|
||||
/// the move-offset chain *and* the mask chain (`Mask::parent`, walked in
|
||||
/// the fragment stage), and `MOVE_CHAIN_LIMIT` said only the first
|
||||
/// (review, 2026-09-07).
|
||||
pub const PARENT_CHAIN_LIMIT: usize = 64;
|
||||
|
||||
impl UiRenderState {
|
||||
@@ -157,15 +113,6 @@ impl UiRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and zeroes the (draws, region_mut rewrites, move_offsets
|
||||
/// writes, text shapes) counters -- call once per frame before
|
||||
/// `update()` to measure exactly that frame, per LAYOUT.md section 8.
|
||||
///
|
||||
/// The fourth is the one a draw count cannot stand in for: a widget
|
||||
/// can be redrawn without re-shaping (`TextView::render` memoizes by
|
||||
/// width) and re-shaped without any extra draw, and it is re-shaping
|
||||
/// that the per-block transcript row exists to avoid -- see
|
||||
/// `transcript_ui`'s `a_delta_into_a_long_reply_shapes_one_block`.
|
||||
pub fn take_counters(&mut self) -> (u64, u64, u64, u64) {
|
||||
(
|
||||
std::mem::take(&mut self.draw_count),
|
||||
@@ -179,8 +126,6 @@ impl UiRenderState {
|
||||
self.mov_count += 1;
|
||||
}
|
||||
|
||||
/// Writes a primitive into the arena and, unless it is
|
||||
/// [`Drawn::No`], into `layer`'s draw order.
|
||||
pub(super) fn write_primitive<P: Primitive>(
|
||||
&mut self,
|
||||
layer: usize,
|
||||
@@ -201,8 +146,6 @@ impl UiRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// A standalone image, which draws with its own bind group rather
|
||||
/// than sharing the layer's one instanced draw.
|
||||
pub(super) fn write_image(
|
||||
&mut self,
|
||||
layer: usize,
|
||||
@@ -225,23 +168,9 @@ impl UiRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compacts every layer's draw order around the primitives freed
|
||||
/// this frame, corrects the handles that moved, and only then hands
|
||||
/// the arena slots back for reuse -- that order is the whole reason
|
||||
/// `Primitives::freed` exists. Once per frame, at the end of
|
||||
/// [`Self::update`], so the harness (which has no renderer) applies
|
||||
/// it exactly as a real backend does.
|
||||
fn apply_free(&mut self) {
|
||||
for (layer, order) in self.layers.iter_mut() {
|
||||
for change in order.apply_free() {
|
||||
// Straight to the handle, never a scan of everything the
|
||||
// owner drew: a widget freed and redrawn in one frame has
|
||||
// *every* one of its primitives renumbered here, so a scan
|
||||
// makes this pass quadratic in that widget's primitive
|
||||
// count -- 1.37s for one 51,200-glyph text block, against
|
||||
// 20ms to shape and rasterise the same text (measured
|
||||
// 2026-09-08). `Primitives::handle_index` is written where
|
||||
// the handle is taken, in `Painter::own`.
|
||||
let owner = self.primitives.owner(change.slot);
|
||||
let Some(idx) = self.primitives.handle_index(change.slot) else {
|
||||
continue;
|
||||
@@ -274,12 +203,6 @@ impl UiRenderState {
|
||||
/// different triggers (a surface resize on every rotation or keyboard
|
||||
/// open; a density change only if the app follows the display to a
|
||||
/// different screen, which Android surfaces separately).
|
||||
///
|
||||
/// Marks the tree for a full redraw when the value actually changes:
|
||||
/// every `Len::dp` already resolved and every glyph already shaped
|
||||
/// (`Text::shape` keys its cache on `(attrs, width, density)`) belongs
|
||||
/// to the old one, and nothing else would ask for them again
|
||||
/// (review, 2026-09-07).
|
||||
pub fn set_density(&mut self, density: f32) {
|
||||
if density != self.density {
|
||||
self.resized = true;
|
||||
@@ -334,30 +257,23 @@ impl UiRenderState {
|
||||
self.last_layout = layout_start.elapsed();
|
||||
self.last_redraw_kind = kind;
|
||||
self.frame_no += 1;
|
||||
// After the redraw and before anything reads the frame: every
|
||||
// slot freed above is still named by its layer's draw order until
|
||||
// this runs.
|
||||
self.apply_free();
|
||||
#[cfg(debug_assertions)]
|
||||
debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(rsc),);
|
||||
}
|
||||
|
||||
/// `Instant::now()` at construction -- see the field's own doc.
|
||||
pub fn epoch(&self) -> Instant {
|
||||
self.epoch
|
||||
}
|
||||
|
||||
/// How many times [`Self::update`] has run, counting from 1.
|
||||
pub fn frame_number(&self) -> u64 {
|
||||
self.frame_no
|
||||
}
|
||||
|
||||
/// How long the last [`Self::update`]'s redraw phase took.
|
||||
pub fn last_layout_duration(&self) -> Duration {
|
||||
self.last_layout
|
||||
}
|
||||
|
||||
/// What the last [`Self::update`] did -- see [`RedrawKind`].
|
||||
pub fn last_redraw_kind(&self) -> RedrawKind {
|
||||
self.last_redraw_kind
|
||||
}
|
||||
@@ -384,17 +300,7 @@ impl UiRenderState {
|
||||
at.map(|at| now.saturating_duration_since(at))
|
||||
}
|
||||
|
||||
/// Primitive instances every currently-active widget owns, summed --
|
||||
/// what `iris::frame`'s `primitives=` reports. Not a per-frame delta:
|
||||
/// `redraw_updates` only rewrites what changed, so this is "how much is
|
||||
/// on screen", which is what a report reads as "did this frame have
|
||||
/// more to draw than the last one", not "how much work did this frame
|
||||
/// do" (`take_counters` answers that).
|
||||
///
|
||||
/// A mask's shape does not count: it is a [`Drawn::No`] primitive
|
||||
/// that is never rasterized, so including it would put one extra on
|
||||
/// the line for every masked widget and make a number Iris reads off
|
||||
/// a phone report disagree with what is drawn.
|
||||
/// Excludes undrawn mask shapes so diagnostics match rasterized primitives.
|
||||
pub fn active_primitive_count(&self) -> usize {
|
||||
self.active
|
||||
.values()
|
||||
@@ -404,7 +310,6 @@ impl UiRenderState {
|
||||
|
||||
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
|
||||
self.clear(rsc);
|
||||
// free all resources & cache
|
||||
if let Some(id) = root {
|
||||
self.draw_inner(
|
||||
0,
|
||||
@@ -419,16 +324,6 @@ impl UiRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The slot an *already-active* widget's `move_offsets` entry chains
|
||||
/// to, read back from `self.active`. Only valid where the parent is
|
||||
/// guaranteed to already be in `self.active` -- true for `redraw()`,
|
||||
/// which targets a widget that was fully drawn on some earlier update,
|
||||
/// but **not** for a widget being drawn as part of its own parent's
|
||||
/// `Widget::draw` call: that parent's `ActiveData` is not inserted
|
||||
/// until its `draw` returns (below), so a child drawn partway through
|
||||
/// it would always read back "no parent" here. `Painter::widget_at`
|
||||
/// avoids that trap by passing its own already-known `move_slot`
|
||||
/// straight through instead of asking `self.active` to look it up.
|
||||
fn move_parent_of(&self, parent: Option<WidgetId>) -> u32 {
|
||||
parent
|
||||
.and_then(|p| self.active.get(&p))
|
||||
@@ -535,9 +430,6 @@ impl UiRenderState {
|
||||
let move_slot = Self::move_slot_for(old_move_slot, parent_move_slot, rsc);
|
||||
|
||||
let inherited_mask = mask;
|
||||
// `Painter::layer` is a cursor widgets advance while assigning
|
||||
// layers to their children. Retain the layer this widget itself was
|
||||
// entered on, not wherever that cursor finishes after `draw`.
|
||||
let inherited_layer = layer;
|
||||
let reuse_child_sizes = old_region.map_or([false; 2], |old| {
|
||||
[
|
||||
@@ -616,16 +508,10 @@ impl UiRenderState {
|
||||
id,
|
||||
} = painter;
|
||||
|
||||
// Whatever the draw did not claim is genuinely gone: this draw
|
||||
// wrote fewer primitives than the last one, or stopped matching
|
||||
// part way. Freeing it here rather than in `remove` is what lets
|
||||
// the draw in between reuse the slots -- see
|
||||
// `Primitives::recycle`.
|
||||
for h in recycle {
|
||||
self.free_primitive(&h);
|
||||
}
|
||||
|
||||
// add to active
|
||||
let active = ActiveData {
|
||||
id,
|
||||
region,
|
||||
@@ -643,7 +529,6 @@ impl UiRenderState {
|
||||
move_applied: Vec2::ZERO,
|
||||
};
|
||||
|
||||
// remove old children that weren't kept
|
||||
for c in &old_children {
|
||||
if !active.children.contains(c) {
|
||||
self.remove_rec(*c, rsc);
|
||||
@@ -655,9 +540,6 @@ impl UiRenderState {
|
||||
size
|
||||
}
|
||||
|
||||
/// This widget's slot in `move_offsets`: the one it already had if it
|
||||
/// is being redrawn, or a fresh one linked to its parent's.
|
||||
///
|
||||
/// A redraw **reuses the slot in place with its delta reset**, never
|
||||
/// reallocates: the geometry this draw is about to write is already
|
||||
/// at its correct absolute position, so a delta accumulated before it
|
||||
@@ -687,11 +569,6 @@ impl UiRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// O(1): write the delta for this widget's own slot in
|
||||
/// `move_offsets`. No primitive is touched and there is no recursion --
|
||||
/// every descendant's primitive references this slot transitively
|
||||
/// through the parent chain the shader walks (`resolve_move`), so it
|
||||
/// picks the new delta up for free. See LAYOUT.md section 2.
|
||||
fn mov(&mut self, id: WidgetId, from: UiRegion, to: UiRegion, rsc: &mut dyn UiRsc) {
|
||||
let Some(active) = self.active.get_mut(&id) else {
|
||||
return;
|
||||
@@ -772,22 +649,11 @@ impl UiRenderState {
|
||||
Some(size)
|
||||
}
|
||||
|
||||
/// Retires `id`'s primitives (unless `keep_primitives`, in which case
|
||||
/// they come back in the returned `ActiveData` for the redraw about to
|
||||
/// happen to recycle -- see `Painter::take_recycled`), drops the mask
|
||||
/// refs they held, and takes the widget out of `active`.
|
||||
///
|
||||
/// The handles stay in the returned `ActiveData` either way, freed or
|
||||
/// not: `remask_shape_users` below reads them, and so does the
|
||||
/// caller. **A caller that passed `keep_primitives: false` must not
|
||||
/// free them again** -- they name slots that may already have been
|
||||
/// handed out.
|
||||
///
|
||||
/// The mask refs are dropped either way: a recycled slot is rewritten
|
||||
/// with whatever mask the *new* draw is under, and that draw takes its
|
||||
/// own ref (`Painter::write_primitive`).
|
||||
///
|
||||
/// NOTE: instance textures are cleared and self.textures freed
|
||||
fn remove(
|
||||
&mut self,
|
||||
id: WidgetId,
|
||||
@@ -862,11 +728,6 @@ impl UiRenderState {
|
||||
active
|
||||
}
|
||||
|
||||
/// Retires one primitive: its arena slot and, if a layer's draw order
|
||||
/// names it, its position there. The two go together -- a slot handed
|
||||
/// out again while its old order entry still names it would be drawn
|
||||
/// twice -- which is why this is one function rather than two lines
|
||||
/// repeated at each call site.
|
||||
fn free_primitive(&mut self, h: &PrimitiveHandle) {
|
||||
self.primitives.free(h);
|
||||
if h.pos != NOT_DRAWN {
|
||||
@@ -874,26 +735,6 @@ impl UiRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// A mask whose shape primitive was just freed clips to a slot that
|
||||
/// now holds something else, so the widget that owns it is marked for
|
||||
/// redraw -- its own `set_mask` is the only thing that resolves the
|
||||
/// slot, and it is the same mechanism a dirty widget already goes
|
||||
/// through.
|
||||
///
|
||||
/// `own` is the mask belonging to the widget being removed and is
|
||||
/// skipped: this runs in the middle of that widget's own redraw,
|
||||
/// which sets its mask again on the way out, and a mark left on
|
||||
/// itself would redraw it every frame from then on. Skipping it is
|
||||
/// also what keeps the O(active) scan off the ordinary path -- a
|
||||
/// plain `.masked()` frees exactly its own shape, so `stale` is empty
|
||||
/// and this returns before touching `active`.
|
||||
///
|
||||
/// Both `Vec`s start empty and stay unallocated in that case, and
|
||||
/// membership is a linear scan of two lists that are a handful long
|
||||
/// (a widget's own primitives, and the live masks): this runs once
|
||||
/// per widget removed, which is once per dirty widget per frame, and
|
||||
/// a set built there would be an allocation on the phone's frame
|
||||
/// path in exchange for nothing at these sizes.
|
||||
fn remask_shape_users(
|
||||
active: &HashMap<WidgetId, ActiveData>,
|
||||
id: WidgetId,
|
||||
@@ -944,15 +785,9 @@ impl UiRenderState {
|
||||
|
||||
pub fn redraw_updates(&mut self, rsc: &mut dyn UiRsc) {
|
||||
while rsc.widgets().has_updates() {
|
||||
// Expand size dependencies before drawing anything. The parent
|
||||
// links are the retained widget tree already used by hit testing
|
||||
// and removal; only the direct-child dependency list is new.
|
||||
let pending: Vec<_> = rsc.widgets().needs_redraw.iter().copied().collect();
|
||||
for mut child in pending {
|
||||
for _ in 0..PARENT_CHAIN_LIMIT {
|
||||
// An exact hint is the child's current answer without a
|
||||
// draw. If both axes still match the retained size, no
|
||||
// parent can observe a size change from this mutation.
|
||||
if self.size_matches_hints(child, rsc) {
|
||||
break;
|
||||
}
|
||||
@@ -972,8 +807,6 @@ impl UiRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
// A dirty ancestor draws its dirty descendants on the way down;
|
||||
// starting those descendants separately would duplicate work.
|
||||
let dirty: Vec<_> = rsc.widgets().needs_redraw.iter().copied().collect();
|
||||
let mut roots = Vec::new();
|
||||
for id in dirty {
|
||||
@@ -1041,20 +874,6 @@ impl UiRenderState {
|
||||
self.active.len()
|
||||
}
|
||||
|
||||
/// Primitive instances still bound for the GPU whose owner is no
|
||||
/// longer in `active`, or whose owner's `ActiveData` no longer names
|
||||
/// them: a copy nothing can move, clip, resize or free, redrawn every
|
||||
/// frame at whatever position it last had. `(slot, owner)` each --
|
||||
/// the arena knows which primitive, not which layer's draw order still
|
||||
/// names it.
|
||||
///
|
||||
/// Asserted empty at the end of every [`Self::update`], because this
|
||||
/// is exactly the shape of the duplicated transcript row on Iris's
|
||||
/// phone (`docs/bench/iris-phone-v2-2026-09-06.md`): counting
|
||||
/// `active` alone cannot see it, since the orphan's owner is very
|
||||
/// much alive -- it is the *earlier* set of primitives that got
|
||||
/// stranded when the widget was drawn a second time without the first
|
||||
/// draw being freed. O(primitives), debug builds only.
|
||||
pub fn orphaned_primitives(&self) -> Vec<(u32, WidgetId)> {
|
||||
let mut orphans = Vec::new();
|
||||
for (slot, owner, _) in self.primitives.live_instances() {
|
||||
@@ -1069,13 +888,6 @@ impl UiRenderState {
|
||||
orphans
|
||||
}
|
||||
|
||||
/// Whether every primitive still bound for the GPU is owned by a live
|
||||
/// widget, decided by counting rather than by walking: an orphan is a
|
||||
/// live instance no `ActiveData` names, so it can only ever make the
|
||||
/// live count exceed the owned one. O(active widgets) -- a few dozen --
|
||||
/// against [`Self::orphaned_primitives`]'s O(primitives), which on a
|
||||
/// transcript is tens of thousands and made a debug build on a phone
|
||||
/// too slow to finish a benchmark run.
|
||||
#[cfg(debug_assertions)]
|
||||
fn primitive_counts_agree(&self) -> bool {
|
||||
let live: usize = self.primitives.live_count();
|
||||
@@ -1083,9 +895,6 @@ impl UiRenderState {
|
||||
live == owned
|
||||
}
|
||||
|
||||
/// The message [`Self::update`]'s orphan assert prints -- built here
|
||||
/// rather than inline so the (allocating, O(primitives)) work only
|
||||
/// happens on the failing path.
|
||||
#[cfg(debug_assertions)]
|
||||
fn orphan_report(&self, rsc: &dyn UiRsc) -> String {
|
||||
let orphans = self.orphaned_primitives();
|
||||
@@ -1130,26 +939,12 @@ impl UiRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// `active[id].region`, corrected by every `move_offsets` delta between
|
||||
/// `id` and the root -- the CPU-side twin of the vertex shader's chain
|
||||
/// walk, over the same arena, so the two cannot disagree about where a
|
||||
/// widget is. O(chain depth), not O(primitives). See LAYOUT.md
|
||||
/// section 2b.
|
||||
pub fn resolved_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<UiRegion> {
|
||||
let active = self.active.get(&id.id())?;
|
||||
// The chain sum is what the shader adds to this widget's
|
||||
// *primitives*, which were written before any of those moves.
|
||||
// `region`, unlike them, has already been shifted by whatever
|
||||
// part of this widget's own slot `mov` put there -- see
|
||||
// `ActiveData::move_applied`, which is exactly that part.
|
||||
let delta = self.resolve_move_chain(active.move_slot, rsc) - active.move_applied;
|
||||
Some(active.region.offset(UiVec2::abs(delta)))
|
||||
}
|
||||
|
||||
/// The plain-Rust twin of `resolve_move` in shader.wgsl: sums the
|
||||
/// pixel delta along the parent chain starting at `slot`. Both walks
|
||||
/// share `PARENT_CHAIN_LIMIT` as their bound so the two cannot disagree
|
||||
/// about where the chain ends.
|
||||
fn resolve_move_chain(&self, slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
|
||||
let offsets = &rsc.ui().move_offsets;
|
||||
let mut delta = Vec2::ZERO;
|
||||
@@ -1162,10 +957,6 @@ impl UiRenderState {
|
||||
return delta;
|
||||
}
|
||||
at = Id::preset(entry.parent);
|
||||
// The chain itself, not just the fact that it was too long: a
|
||||
// cycle and a tree genuinely nested deeper than the shader can
|
||||
// follow are different faults with different fixes, and the
|
||||
// slot numbers are the only thing that tells them apart.
|
||||
debug_assert!(
|
||||
i + 1 < PARENT_CHAIN_LIMIT,
|
||||
"move offset chain exceeded PARENT_CHAIN_LIMIT ({PARENT_CHAIN_LIMIT}): {chain} \
|
||||
@@ -1178,10 +969,6 @@ impl UiRenderState {
|
||||
delta
|
||||
}
|
||||
|
||||
/// The parent chain from `slot`, as `slot(dx, dy) -> ...`, walked twice
|
||||
/// `PARENT_CHAIN_LIMIT` so a cycle shows up as a repeated slot number
|
||||
/// rather than as a chain that merely stops. Only ever called from the
|
||||
/// failed assertion above.
|
||||
fn move_chain_debug(slot: MoveIdx, offsets: &[MoveOffset]) -> String {
|
||||
let mut parts = Vec::new();
|
||||
let mut at = slot;
|
||||
@@ -1201,14 +988,6 @@ impl UiRenderState {
|
||||
parts.join(" -> ")
|
||||
}
|
||||
|
||||
/// One primitive's corners in window pixels -- the transliteration of
|
||||
/// `shader.wgsl`'s `corners_of`, `floor` for `floor`. The rounding is
|
||||
/// the whole reason this is not `region.to_px()`: the shader floors
|
||||
/// each half separately before adding the move delta, and a hit test
|
||||
/// that skipped it would disagree with the pixels by up to one along
|
||||
/// each edge -- invisible in every test written against a whole-pixel
|
||||
/// layout and wrong on the phone, whose 2.55 density makes nothing
|
||||
/// land on a whole pixel.
|
||||
pub fn primitive_corners(&self, slot: u32, rsc: &dyn UiRsc) -> PixelRegion {
|
||||
let inst = self.primitives.instance(slot);
|
||||
let delta = self.resolve_move_chain(inst.move_idx, rsc);
|
||||
@@ -1220,27 +999,10 @@ impl UiRenderState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a mask's clip actually is on screen: the box of the
|
||||
/// primitive it references. Its *shape* within that box is
|
||||
/// [`Self::mask_coverage`]'s -- this is the bounding box, which is
|
||||
/// what a test asking "is the clip over the right part of the screen"
|
||||
/// wants and all a square-cornered mask has ever had.
|
||||
pub fn mask_region(&self, mask: MaskIdx, rsc: &dyn UiRsc) -> PixelRegion {
|
||||
self.primitive_corners(rsc.ui().masks[mask.idx()].primitive, rsc)
|
||||
}
|
||||
|
||||
/// How much of the pixel at `pos` (window pixels) survives `mask` and
|
||||
/// every mask it nests inside: the referenced primitives' own
|
||||
/// coverage, multiplied along the chain. The CPU half of
|
||||
/// `shader.wgsl`'s `fs_main` mask loop -- same order, same bound, same
|
||||
/// `rounded_rect_coverage` -- so a corner that cannot be tapped and a
|
||||
/// corner that is not drawn are the same corner (LAYOUT.md's "Masks
|
||||
/// with a shape", point 4).
|
||||
///
|
||||
/// A mask whose shape is not a rect covers everything, exactly as the
|
||||
/// shader's own `mask_coverage` does: `Painter::set_mask_to` rejects
|
||||
/// those by name, so this is the unreachable half of the same
|
||||
/// agreement rather than a second policy.
|
||||
pub fn mask_coverage(&self, mask: MaskIdx, pos: Vec2, rsc: &dyn UiRsc) -> f32 {
|
||||
let mut coverage = 1.0;
|
||||
let mut at = mask;
|
||||
@@ -1264,18 +1026,10 @@ impl UiRenderState {
|
||||
coverage
|
||||
}
|
||||
|
||||
/// Whether `pos` is inside `mask` at all -- more than half covered,
|
||||
/// which is where the drawn edge is (`rounded_rect_coverage`'s doc).
|
||||
/// What a hit test asks.
|
||||
pub fn mask_admits(&self, mask: MaskIdx, pos: Vec2, rsc: &dyn UiRsc) -> bool {
|
||||
self.mask_coverage(mask, pos, rsc) > 0.5
|
||||
}
|
||||
|
||||
/// The first primitive `id`'s subtree wrote this frame, depth first
|
||||
/// in draw order -- what a mask pointed at a widget clips to
|
||||
/// (`Painter::set_mask_to_widget`). A widget that draws more than one
|
||||
/// (a bordered rect is one primitive; a card with a stripe is two)
|
||||
/// gives its first; a widget that wants another names it.
|
||||
pub fn first_primitive(&self, id: WidgetId) -> Option<u32> {
|
||||
let active = self.active.get(&id)?;
|
||||
if let Some(h) = active.primitives.first() {
|
||||
@@ -1292,13 +1046,8 @@ impl UiRenderState {
|
||||
Some(region.to_px(self.output_size))
|
||||
}
|
||||
|
||||
/// redraws a widget that's currently active (drawn)
|
||||
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
|
||||
rsc.widgets_mut().needs_redraw.remove(&id);
|
||||
// An ancestor is drawing this widget right now, and that draw is
|
||||
// about to write fresh primitives for it. Drawing it a second time
|
||||
// here would leave one of the two copies on screen with nothing
|
||||
// owning it -- see `draw_started`'s own doc.
|
||||
if self.draw_started.contains(&id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,8 +45,6 @@ impl<T, I: IdNum> Default for Arena<T, I> {
|
||||
pub struct TrackedArena<T, I> {
|
||||
inner: Arena<T, I>,
|
||||
refs: Vec<u32>,
|
||||
/// Which entries changed since the last upload. Was a `bool`, so one
|
||||
/// widget getting a move offset re-uploaded every other widget's.
|
||||
pub dirty: Dirty,
|
||||
}
|
||||
|
||||
@@ -73,17 +71,11 @@ impl<T, I: IdNum> TrackedArena<T, I> {
|
||||
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.dirty.mark(id.idx());
|
||||
&mut self.inner.data[id.idx()]
|
||||
}
|
||||
|
||||
/// The entries and the dirty set together -- see
|
||||
/// `PrimitiveVec::for_upload`.
|
||||
pub fn for_upload(&mut self) -> (&[T], &mut Dirty) {
|
||||
(&self.inner.data, &mut self.dirty)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
//! Which entries of a GPU-bound array changed since the last upload.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
/// A bitset of dirty entries, coalesced into a handful of ranges when it
|
||||
/// is time to upload.
|
||||
///
|
||||
/// **Why a bitset** rather than the two obvious alternatives, both of
|
||||
/// which were measured against the bench fixture before this was written
|
||||
/// (`scripts/rigs/ui-profile`'s `arena_churn`). A `min..max` span is far
|
||||
/// too coarse: a frame's changes land in 5-20 runs scattered across the
|
||||
/// whole arena, so the span is very nearly the whole buffer. A `Vec` of
|
||||
/// touched indices is too expensive to *write*: a streaming frame marks
|
||||
/// several thousand entries, which would mean an allocation and a sort
|
||||
/// per frame. Marking a bit is O(1), allocation-free and idempotent, and
|
||||
/// the scan that reads it back is one word per 64 entries.
|
||||
#[derive(Default)]
|
||||
pub struct Dirty {
|
||||
words: Vec<u64>,
|
||||
@@ -26,7 +12,6 @@ pub struct Dirty {
|
||||
}
|
||||
|
||||
impl Dirty {
|
||||
/// Nothing uploaded yet, so nothing may be assumed about the buffer.
|
||||
pub fn new_all() -> Self {
|
||||
Self {
|
||||
words: Vec::new(),
|
||||
@@ -76,14 +61,6 @@ impl Dirty {
|
||||
!self.all && self.words.iter().all(|w| *w == 0)
|
||||
}
|
||||
|
||||
/// The ranges to upload, in ascending order, merging two runs
|
||||
/// separated by a gap of fewer than `gap` entries.
|
||||
///
|
||||
/// Merging trades bytes for `write_buffer` calls, and the fixture
|
||||
/// says the trade is very cheap in one direction: over a fling, a
|
||||
/// 1 KiB gap costs 0.1% more bytes than merging nothing at all and
|
||||
/// halves the worst-case call count (23 to 13). Past that it stops
|
||||
/// paying -- 4 KiB is +2% bytes for two fewer calls.
|
||||
pub fn ranges(&self, len: usize, gap: usize) -> Vec<Range<usize>> {
|
||||
if self.all {
|
||||
return Vec::from_iter((len > 0).then_some(0..len));
|
||||
@@ -93,15 +70,12 @@ impl Dirty {
|
||||
let mut bits = *word;
|
||||
while bits != 0 {
|
||||
let start = w * 64 + bits.trailing_zeros() as usize;
|
||||
// The run of set bits starting here, within this word.
|
||||
let run = (bits >> (start - w * 64)).trailing_ones() as usize;
|
||||
let end = (start + run).min(len);
|
||||
if start >= len {
|
||||
break;
|
||||
}
|
||||
match ranges.last_mut() {
|
||||
// `start - last.end` is the gap; equal ends means
|
||||
// adjacent, which always merges.
|
||||
Some(last) if start - last.end <= gap => last.end = end,
|
||||
_ => ranges.push(start..end),
|
||||
}
|
||||
@@ -158,8 +132,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ranges_stop_at_the_length() {
|
||||
// Entries marked and then dropped by a shrink must not be
|
||||
// uploaded past the end of what the caller is writing.
|
||||
assert_eq!(marked(&[1, 2, 40], 3, 0), vec![1..3]);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,6 @@ impl<I: IdNum> IdTracker<I> {
|
||||
|
||||
impl<I: IdNum> Id<I> {
|
||||
#[allow(dead_code)]
|
||||
/// for debug purposes; should this be exposed?
|
||||
/// generally you want to use labels with widgets
|
||||
pub(crate) fn raw(id: I) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
@@ -20,12 +20,9 @@ const impl<
|
||||
T: const Add<Output = T> + const Sub<Output = T> + const Mul<Output = T> + const DivOr + Copy,
|
||||
> LerpUtil for T
|
||||
{
|
||||
/// linear interpolation
|
||||
/// from * (1.0 - self) + to * self
|
||||
fn lerp(self, from: Self, to: Self) -> Self {
|
||||
from + (to - from) * self
|
||||
}
|
||||
/// inverse of lerp
|
||||
fn lerp_inv(self, from: Self, to: Self) -> Self {
|
||||
(self - from).div_or(to - from, from)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ impl<Trait: ?Sized> TypeMap<Trait> {
|
||||
}
|
||||
|
||||
fn convert_mut<T: Unsize<Trait>>(entry: &mut Box<Trait>) -> &mut T {
|
||||
// allegedly this is just what Any does...
|
||||
unsafe { &mut *(entry.as_mut() as *mut Trait as *mut T) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ impl Vec2 {
|
||||
}
|
||||
}
|
||||
|
||||
// this version looks kinda cool... is it more readable? more annoying to copy and change though
|
||||
impl_op!(impl Add for Vec2: add x y);
|
||||
impl_op!(Vec2 Sub sub; x y);
|
||||
impl_op!(Vec2 Mul mul; x y);
|
||||
|
||||
@@ -3,7 +3,6 @@ use crate::Widget;
|
||||
pub struct WidgetData {
|
||||
pub widget: Box<dyn Widget>,
|
||||
pub label: String,
|
||||
/// dynamic borrow checking
|
||||
pub borrowed: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,6 @@ use crate::{
|
||||
|
||||
pub type WidgetId = SlotId;
|
||||
|
||||
/// An identifier for a widget that can index a UI or event ctx to get it.
|
||||
/// This is a strong handle that does not impl Clone, and when it is dropped,
|
||||
/// a signal is sent to the owning UI to clean up the resources.
|
||||
///
|
||||
/// TODO: ergonomic clones when they get put in rust-analyzer & don't cause ICEs?
|
||||
pub struct StrongWidget<W: ?Sized = dyn Widget> {
|
||||
pub(super) id: WidgetId,
|
||||
counter: RefCounter,
|
||||
@@ -19,8 +14,6 @@ pub struct StrongWidget<W: ?Sized = dyn Widget> {
|
||||
ty: *const W,
|
||||
}
|
||||
|
||||
/// A weak handle to a widget.
|
||||
/// Will not keep it alive, but can still be used for indexing like WidgetHandle.
|
||||
pub struct WeakWidget<W: ?Sized = dyn Widget> {
|
||||
pub(super) id: WidgetId,
|
||||
#[allow(unused)]
|
||||
|
||||
@@ -43,7 +43,6 @@ impl<Rsc, const LEN: usize> WidgetArrLike<Rsc, LEN, ArrTag> for WidgetArr<LEN> {
|
||||
}
|
||||
}
|
||||
|
||||
// variadic generics please save us
|
||||
macro_rules! impl_widget_arr {
|
||||
($n:expr;$($W:ident)*) => {
|
||||
impl_widget_arr!($n;$($W)*;$(${concat($W,Tag)})*);
|
||||
|
||||
@@ -18,12 +18,10 @@ pub use widgets::*;
|
||||
pub trait Widget: Any {
|
||||
fn draw(&mut self, painter: &mut Painter);
|
||||
|
||||
/// An exact, context-free length known without drawing or inspecting children.
|
||||
fn size_hint(&self, _axis: Axis) -> Option<Len> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether the draw result is independent of the offered region.
|
||||
fn is_size_independent(&self) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -32,12 +30,10 @@ pub trait Widget: Any {
|
||||
false
|
||||
}
|
||||
|
||||
/// The AccessKit role for a labelled widget.
|
||||
fn access_role(&self) -> accesskit::Role {
|
||||
accesskit::Role::Unknown
|
||||
}
|
||||
|
||||
/// Advance an animation and report whether it needs another frame.
|
||||
#[allow(unused_variables)]
|
||||
fn tick(&mut self, now: std::time::Instant) -> bool {
|
||||
false
|
||||
@@ -68,9 +64,6 @@ impl dyn Widget {
|
||||
}
|
||||
}
|
||||
|
||||
/// A function that returns a widget given a UI.
|
||||
/// Useful for defining trait functions on widgets that create a parent widget so that the children
|
||||
/// don't need to be IDs yet
|
||||
pub trait WidgetFn<State, W: Widget + ?Sized>: FnOnce(&mut State) -> W {}
|
||||
impl<State, W: Widget + ?Sized, F: FnOnce(&mut State) -> W> WidgetFn<State, W> for F {}
|
||||
|
||||
|
||||
@@ -11,10 +11,6 @@ pub struct Widgets {
|
||||
send: Sender<WidgetId>,
|
||||
recv: Receiver<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>,
|
||||
}
|
||||
|
||||
@@ -44,8 +40,6 @@ impl Widgets {
|
||||
Some(self.vec.get_mut(id)?.widget.as_mut())
|
||||
}
|
||||
|
||||
/// get_dyn but dynamic borrow checking of widgets
|
||||
/// lets you do recursive (tree) operations, like the painter does
|
||||
pub(crate) fn get_dyn_dynamic<'a>(&self, id: WidgetId) -> WidgetWrapper<'a> {
|
||||
// SAFETY: must guarantee no other mutable references to this widget exist
|
||||
// done through the borrow variable
|
||||
@@ -101,18 +95,12 @@ impl Widgets {
|
||||
&self.data(id.id()).unwrap().label
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1,27 +1,3 @@
|
||||
//! (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;
|
||||
|
||||
@@ -1,21 +1,3 @@
|
||||
//! 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; `scripts/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};
|
||||
|
||||
@@ -31,10 +13,6 @@ struct State {
|
||||
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. ";
|
||||
@@ -42,9 +20,6 @@ fn row_text(i: usize) -> String {
|
||||
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()
|
||||
@@ -84,11 +59,6 @@ fn build_row<Rsc: UiRsc + 'static>(rsc: &mut Rsc, i: usize) -> StrongWidget {
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -104,12 +74,6 @@ impl DefaultAppState for State {
|
||||
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()
|
||||
|
||||
@@ -5,10 +5,6 @@ fn main() {
|
||||
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)]
|
||||
pub struct Client {
|
||||
ui_state: DefaultUiState,
|
||||
|
||||
@@ -18,11 +18,6 @@ struct Input {
|
||||
}
|
||||
|
||||
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,
|
||||
body: Block,
|
||||
@@ -66,8 +61,6 @@ pub fn widget_trait(input: TokenStream) -> TokenStream {
|
||||
fns,
|
||||
} = parse_macro_input!(input as Input);
|
||||
|
||||
// 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 })
|
||||
|
||||
+3
-19
@@ -3,30 +3,14 @@ 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.
|
||||
# Replays harness `.touch` files through Wayland's virtual-pointer protocol;
|
||||
# headless sway has no input devices for coordinate-driving tools to move.
|
||||
[[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.
|
||||
# Share the harness parser so both layers interpret recordings identically.
|
||||
iris = { path = ".." }
|
||||
wayland-client = "0.31.15"
|
||||
wayland-protocols-wlr = { version = "0.3.12", features = ["client"] }
|
||||
@@ -1,19 +1,3 @@
|
||||
//! 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;
|
||||
@@ -24,12 +8,8 @@ use wayland_protocols_wlr::virtual_pointer::v1::client::{
|
||||
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)]
|
||||
@@ -126,9 +106,6 @@ fn main() {
|
||||
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
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
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
|
||||
@@ -103,8 +102,6 @@ if ! swaymsg -t get_version >/dev/null 2>&1; then
|
||||
}
|
||||
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
|
||||
@@ -165,7 +162,6 @@ while [ $i -lt 40 ]; do
|
||||
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
|
||||
|
||||
+1
-8
@@ -1,11 +1,4 @@
|
||||
# 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"
|
||||
channel = "nightly"
|
||||
components = ["clippy", "rustfmt"]
|
||||
targets = ["aarch64-linux-android", "x86_64-linux-android"]
|
||||
@@ -1,8 +1,3 @@
|
||||
//! Pass conditions for RUST.md's I4, exercised the same way
|
||||
//! `layout_tests.rs` exercises LAYOUT.md's: `AccessTree` only touches
|
||||
//! `Widgets`/`UiRenderState`, neither of which needs a GPU or a window, so
|
||||
//! it can be driven directly against `layout_tests::TestRsc`.
|
||||
|
||||
use crate::layout_tests::TestRsc;
|
||||
use crate::prelude::*;
|
||||
|
||||
@@ -22,7 +17,6 @@ fn a_named_widget_reaches_the_tree_with_its_role_and_bounds() {
|
||||
.update(rsc.widgets(), &render, &rsc)
|
||||
.expect("a first draw with a named widget must produce a tree");
|
||||
|
||||
// One node for the widget, one for the synthetic window root.
|
||||
assert_eq!(update.nodes.len(), 2);
|
||||
let (_, node) = update
|
||||
.nodes
|
||||
@@ -59,10 +53,6 @@ fn a_widget_with_no_label_never_reaches_the_tree() {
|
||||
);
|
||||
}
|
||||
|
||||
/// LAYOUT.md's "a moved subtree" lesson applies here too: `resolved_region`
|
||||
/// (which `window_region` sits on) walks the move-offset chain, so a
|
||||
/// widget moved via `Offset` -- not redrawn from scratch -- must still
|
||||
/// report where it actually ended up.
|
||||
#[test]
|
||||
fn bounds_follow_a_moved_widget_and_updates_stay_incremental() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -86,17 +76,10 @@ fn bounds_follow_a_moved_widget_and_updates_stay_incremental() {
|
||||
.expect("the first draw is always a change");
|
||||
assert_eq!(access.take_rebuilds(), 1);
|
||||
|
||||
// Unchanged frame: nothing moved, nothing renamed -- `update` must
|
||||
// report no change, and the rebuild counter (I4's twin of
|
||||
// `take_counters`) must stay at 0.
|
||||
render.update(&root, &mut rsc);
|
||||
assert!(access.update(rsc.widgets(), &render, &rsc).is_none());
|
||||
assert_eq!(access.take_rebuilds(), 0);
|
||||
|
||||
// Move the child via `Offset` (a move-offset write, not necessarily a
|
||||
// full redraw of the leaf -- see `resolve_move_chain`) and confirm the
|
||||
// reported bounds shifted by exactly that amount, in exactly one more
|
||||
// rebuild.
|
||||
let before = render
|
||||
.window_region(&leaf, &rsc)
|
||||
.expect("active before the move");
|
||||
@@ -110,10 +93,6 @@ fn bounds_follow_a_moved_widget_and_updates_stay_incremental() {
|
||||
let after = render
|
||||
.window_region(&leaf, &rsc)
|
||||
.expect("still active after the move");
|
||||
// Not asserting the exact delta: `Offset`'s own `amt` -> pixel mapping
|
||||
// is that widget's business, not this tree's. What I4 owns is that
|
||||
// `AccessTree` reports whatever `window_region` says *now* -- so the
|
||||
// node must have moved, and in the direction the offset moved it.
|
||||
assert!(
|
||||
after.top_left.x > before.top_left.x,
|
||||
"the leaf's reported bounds must move right along with its offset"
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
//! I4 (RUST.md): the Android half of the AccessKit push, over
|
||||
//! `accesskit_android::Adapter` and android-view's
|
||||
//! `AccessibilityNodeProvider`. Carries E1's mitigation for the adapter's
|
||||
//! reproducible abort: `accesskit_android`'s `State` (0.4.0 and 0.8.0
|
||||
//! alike) never moves back to `Inactive` once a client attaches, so once
|
||||
//! one has, every later `QueuedEvents::raise` reaches
|
||||
//! `AccessibilityManager.sendAccessibilityEvent` -- which throws if
|
||||
//! accessibility has since been switched off (or the client detached),
|
||||
//! and android-view's `panic = "abort"` turns that Java exception into a
|
||||
//! process kill. `raise_if_enabled` is the gate: ask
|
||||
//! `AccessibilityManager.isEnabled()` immediately before every `raise`
|
||||
//! and drop the events instead of calling it when the answer is no. See
|
||||
//! RUST.md's E1 box for the full repro.
|
||||
use accesskit::{ActionHandler, ActionRequest, ActivationHandler, TreeUpdate};
|
||||
use accesskit_android::QueuedEvents;
|
||||
use android_view::{
|
||||
@@ -37,11 +24,6 @@ impl ActivationHandler for AndroidAccessSource<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every AccessKit action request is inert here -- see this module's doc
|
||||
/// comment and `default/access.rs`'s matching handler for why: a screen
|
||||
/// reader's tap on a named node is a real touch delivered at that node's
|
||||
/// bounds, which the ordinary pointer path already handles once the
|
||||
/// bounds `AccessTree` reports are right.
|
||||
pub(super) struct NullActionHandler;
|
||||
impl ActionHandler for NullActionHandler {
|
||||
fn do_action(&mut self, _request: ActionRequest) {}
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
//! `InputConnection`, implemented directly against a focused `TextEdit`
|
||||
//! rather than against a stand-in editor the way android-view's own demo
|
||||
//! does over its `parley::PlainEditor` -- I1 already put parley behind
|
||||
//! `TextEdit`, so this is that same bridge, just wired to iris's widget
|
||||
//! instead of a bespoke one. Follows `demo/src/lib.rs`'s
|
||||
//! `impl InputConnection for DemoViewPeer`, which is where RUST.md's E1
|
||||
//! found the shape this needs (`text_before_cursor` is what gets Gboard's
|
||||
//! suggestion strip to read real words out of the buffer).
|
||||
//!
|
||||
//! Two things the demo tracks that this does not, both noted rather than
|
||||
//! silently dropped: a real "composing region" distinct from the
|
||||
//! selection (`set_composing_region` here just moves the caret, since
|
||||
//! `TextEdit` has no third range to hold one), and batch-edit coalescing
|
||||
//! (`begin`/`end_batch_edit` are no-ops -- a redraw mid-batch costs a frame
|
||||
//! it does not need to, not correctness).
|
||||
|
||||
use crate::prelude::*;
|
||||
use android_view::{
|
||||
CAP_MODE_SENTENCES, CallbackCtx, EditorInfo, IME_FLAG_NO_ENTER_ACTION, IME_FLAG_NO_EXTRACT_UI,
|
||||
@@ -50,25 +34,6 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
self.state.android_state().focus
|
||||
}
|
||||
|
||||
/// Tell Gboard where the caret/selection and the composing region
|
||||
/// actually are, via `InputMethodManager.updateSelection` -- every one
|
||||
/// of android-view's own demo's `set_composing_text_internal`/`render`
|
||||
/// calls this, and this bridge never did, which is what left Gboard's
|
||||
/// own model of the field diverging from `TextEdit`'s real one after
|
||||
/// the very first edit (RUST.md's P0 box, "doesn't enter it until I
|
||||
/// hit space, and also doesn't move cursor forward" -- Gboard holds
|
||||
/// its composing keystrokes back until it believes the app has caught
|
||||
/// up, and without this call it never does). Called from
|
||||
/// [`IrisViewPeer::after_input`], the one tail every touch/key/IME
|
||||
/// callback already runs through, rather than duplicated at each of
|
||||
/// this file's mutating methods.
|
||||
///
|
||||
/// `candidates_start`/`candidates_end` report the composing region;
|
||||
/// `-1, -1` when nothing is composing, matching `EditorInfo`'s own
|
||||
/// convention. `compose_len` is tracked in `char`s (this module's doc
|
||||
/// comment), so this reports it as that many UTF-16 units back from the
|
||||
/// caret -- exact for the common BMP case, the same approximation
|
||||
/// `set_composing_text` already makes.
|
||||
pub(super) fn update_ime_selection(&mut self, ctx: &mut CallbackCtx) {
|
||||
let Some(focus) = self.focus() else { return };
|
||||
let text = &self.rsc[focus];
|
||||
@@ -219,10 +184,6 @@ impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
|
||||
before_length: i32,
|
||||
after_length: i32,
|
||||
) -> bool {
|
||||
// Approximated as UTF-16 units rather than Unicode scalar values --
|
||||
// the two differ only outside the Basic Multilingual Plane, which
|
||||
// this widget tree does not exercise today. Worth revisiting if a
|
||||
// field ever needs to edit emoji or other astral-plane text well.
|
||||
self.delete_surrounding_text(ctx, before_length, after_length)
|
||||
}
|
||||
|
||||
@@ -235,11 +196,6 @@ impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
|
||||
let Some(focus) = self.focus() else {
|
||||
return false;
|
||||
};
|
||||
// The IME re-sends its whole composition on every keystroke;
|
||||
// `compose_len` (chars, not bytes -- `TextEditCtx::replace`'s unit)
|
||||
// is what lets `replace` remove exactly what it inserted last time.
|
||||
// The same shape as `default::DefaultApp`'s `Ime::Preedit` handling
|
||||
// for winit.
|
||||
let compose_len = self.state.android_state().compose_len;
|
||||
focus.edit(&mut self.rsc).replace(compose_len, text);
|
||||
self.state.android_state_mut().compose_len = text.chars().count();
|
||||
@@ -267,9 +223,6 @@ impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
|
||||
};
|
||||
let text = &self.rsc[focus];
|
||||
let content = text.text();
|
||||
// Collapsed to `end`: `TextEditCtx` has no range-selection setter
|
||||
// yet (nothing before I2 needed one), so an IME-driven selection
|
||||
// lands the caret at its focus end rather than spanning both.
|
||||
let byte = utf16_to_byte(content, end.max(0) as usize);
|
||||
focus.edit(&mut self.rsc).set_cursor_byte(byte);
|
||||
let _ = start;
|
||||
@@ -278,8 +231,6 @@ impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
|
||||
}
|
||||
|
||||
fn perform_editor_action(&mut self, _ctx: &mut CallbackCtx, _editor_action: i32) -> bool {
|
||||
// `IME_FLAG_NO_ENTER_ACTION` above asks the IME not to offer one;
|
||||
// nothing here needs handling it yet.
|
||||
false
|
||||
}
|
||||
|
||||
@@ -311,8 +262,6 @@ impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
|
||||
}
|
||||
|
||||
fn request_cursor_updates(&mut self, _ctx: &mut CallbackCtx, _cursor_update_mode: i32) -> bool {
|
||||
// No cursor-anchor UI to feed -- see RUST.md's I2 notes on what
|
||||
// this backend does not do yet.
|
||||
false
|
||||
}
|
||||
}
|
||||
+1
-48
@@ -1,25 +1,3 @@
|
||||
//! Window insets, fed in from outside `ViewPeer`.
|
||||
//!
|
||||
//! android-view's registered native methods (`view.rs` in that crate) cover
|
||||
//! touch, keys, focus, the surface and the IME -- there is nothing for
|
||||
//! `View.onApplyWindowInsets`, because android-view's own demo does not
|
||||
//! need it. The back gesture needed no new plumbing at all: with no
|
||||
//! `OnBackPressedCallback` registered, Android still delivers it as an
|
||||
//! ordinary `KEYCODE_BACK` `KeyEvent` through the ordinary key path (see
|
||||
//! `view.rs`'s `on_key_down`), which is the legacy behaviour every app gets
|
||||
//! by default and is enough for "the back gesture as an event". Insets have
|
||||
//! no such stand-in, so this module registers one more native method by
|
||||
//! hand, on the app's own `View` subclass rather than on android-view's.
|
||||
//!
|
||||
//! The peer id android-view hands back from `register_view_peer` is opaque
|
||||
//! outside that crate (`with_peer` is `pub(crate)` there), so there is no
|
||||
//! way to reach an existing `IrisViewPeer` from a JNI entry point we define
|
||||
//! ourselves. Instead of forking android-view to add a hook, `new_peer`
|
||||
//! (`view.rs`) inserts the *same* id into this module's own map, pointing
|
||||
//! at a plain `Rc<RefCell<Shared>>` cloned into `AndroidUiState` too --
|
||||
//! so writing here is reading there, with no dependency in either
|
||||
//! direction on the other's internals.
|
||||
|
||||
use android_view::{
|
||||
View,
|
||||
jni::{
|
||||
@@ -45,37 +23,14 @@ pub struct Insets {
|
||||
pub top: i32,
|
||||
pub right: i32,
|
||||
pub bottom: i32,
|
||||
/// The keyboard's own inset (`WindowInsets.Type.ime()`), in physical
|
||||
/// pixels, separate from `bottom` (the system bars): a layout wants to
|
||||
/// know about the keyboard specifically, since it usually means "make
|
||||
/// room" rather than "stay clear of a corner".
|
||||
pub ime_bottom: i32,
|
||||
/// `WindowInsets.isVisible(ime())` -- whether the keyboard is up, which
|
||||
/// is **not** the same question as `ime_bottom > 0` and is why the two
|
||||
/// are carried separately. They disagree for the frames the keyboard
|
||||
/// spends sliding: visible, with a height still on its way to the full
|
||||
/// one. Anything asking "make how much room" reads `ime_bottom`;
|
||||
/// anything asking "is the keyboard up" reads this. See
|
||||
/// `MainActivity.java`'s comment for the history -- the height used to
|
||||
/// be sent *as* this boolean, which is what left the composer padded by
|
||||
/// one pixel on Iris's phone.
|
||||
pub ime_visible: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Shared {
|
||||
pub insets: Insets,
|
||||
/// How many times Java has called `applyWindowInsetsNative` for this
|
||||
/// peer, whether or not the numbers changed. Deliberately **not** a
|
||||
/// field of `Insets`, which is compared for equality each frame to
|
||||
/// decide whether to re-run `on_insets_changed`; a counter in there
|
||||
/// would make every dispatch look like a change.
|
||||
///
|
||||
/// It exists because "the keyboard does not push anything up" has two
|
||||
/// completely different causes that look identical on screen -- the
|
||||
/// listener never fired, or it fired with a zero `ime_bottom` -- and
|
||||
/// Iris has no logcat on her phone (docs/IRIS_TODO.md). This number is
|
||||
/// in the `Diagnostics` overlay, so one screenshot separates them.
|
||||
/// Exposed in diagnostics to distinguish missing callbacks from zero insets.
|
||||
pub updates: u64,
|
||||
}
|
||||
|
||||
@@ -125,8 +80,6 @@ extern "system" fn apply_window_insets<'local>(
|
||||
};
|
||||
shared.updates += 1;
|
||||
}
|
||||
// Insets can change (the keyboard opening) with no resize and no
|
||||
// touch, so nothing else here would otherwise ask for a frame.
|
||||
view.post_frame_callback(&mut env);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
//! iris's second windowing backend: `android-view` (a `SurfaceView` plus a
|
||||
//! JNI `ViewPeer`) instead of winit. See RUST.md's I2 for why this exists
|
||||
//! as a second backend rather than winit's own (unfinished, and blocked on
|
||||
//! `android-activity`'s backend-feature requirement) Android support, and
|
||||
//! for the pass condition this was built against.
|
||||
//!
|
||||
//! Structured to mirror `default/` module for module: `view.rs` is that
|
||||
//! module's `app.rs` + `state.rs` combined (android-view has one harness
|
||||
//! type, `ViewPeer`, where winit splits `ApplicationHandler` from the
|
||||
//! per-window state), `render.rs` is `render.rs`, `input.rs` is `input.rs`,
|
||||
//! `attr.rs` is `attr.rs`. `ime.rs` and `insets.rs` have no winit
|
||||
//! counterpart: winit cannot drive an IME beyond `Ime::Preedit`/`Commit`
|
||||
//! (RUST.md's E1) and has no concept of Android's window insets at all.
|
||||
|
||||
mod access;
|
||||
mod attr;
|
||||
mod ime;
|
||||
|
||||
@@ -22,16 +22,10 @@ impl<T: HasAndroidUiState> OpenUrl for T {
|
||||
}
|
||||
}
|
||||
|
||||
/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the view's
|
||||
/// own context.
|
||||
///
|
||||
/// `FLAG_ACTIVITY_NEW_TASK` because the context here is the view's, which
|
||||
/// may be an application context rather than the activity's -- Android
|
||||
/// throws `AndroidRuntimeException` for a non-activity context without it,
|
||||
/// and it is harmless when the context *is* an activity's.
|
||||
///
|
||||
/// Every failure is logged with the URL and returns; there is nothing to
|
||||
/// fall back to, and the reader will see that nothing happened.
|
||||
pub(super) fn open_url<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, url: &str) {
|
||||
match try_open_url(env, view, url) {
|
||||
Ok(()) => {}
|
||||
|
||||
@@ -48,9 +48,6 @@ pub struct AndroidRenderer {
|
||||
config: SurfaceConfiguration,
|
||||
encoder: CommandEncoder,
|
||||
pub ui: UiRenderNode,
|
||||
/// The adapter identity, kept past `new()` for the Diagnostics page --
|
||||
/// `Adapter` itself is not `Clone`, so the three fields the page shows
|
||||
/// are copied out once here rather than holding the adapter.
|
||||
pub adapter_name: String,
|
||||
pub adapter_backend: Backend,
|
||||
pub adapter_driver: String,
|
||||
@@ -59,12 +56,6 @@ pub struct AndroidRenderer {
|
||||
/// `new()`, kept here so the Diagnostics page and the per-frame log in
|
||||
/// `update()` can both read it without a global.
|
||||
pub wgpu_errors: iris_core::WgpuErrorLog,
|
||||
/// Frames drawn on this surface -- what gates the first-10-frames log
|
||||
/// `update()` writes (RUST.md's P0 box, "the first input frame"
|
||||
/// investigation): a fresh surface is exactly what Iris's own report
|
||||
/// says renders correctly at first, so the frames that matter are the
|
||||
/// first several after each `surface_changed`, not an arbitrary window
|
||||
/// during a long-running session.
|
||||
frame_count: u64,
|
||||
/// Physical pixels per dp -- see `android::view::AndroidUiState::
|
||||
/// content_scale`'s field comment for what this feeds.
|
||||
@@ -82,48 +73,17 @@ pub struct AndroidRenderer {
|
||||
pub struct FrameDiagnostics {
|
||||
pub masks_resized: bool,
|
||||
pub moves_resized: bool,
|
||||
/// From the previous frame's `update()` -- see the struct doc.
|
||||
pub atlas_pages_grown_prev: u64,
|
||||
pub image_bind_group_creates_prev: u64,
|
||||
}
|
||||
|
||||
impl AndroidRenderer {
|
||||
/// `Err` holds a full, human-readable report for **every** way this
|
||||
/// can fail -- no surface, no adapter, no device, or wgpu's own error
|
||||
/// text (`UiRenderNode::new`'s doc comment) plus the adapter identity
|
||||
/// and the limits/downlevel flags bind-group-layout validation checks
|
||||
/// against -- rather than the panic wgpu's default error handler would
|
||||
/// otherwise raise with no caller able to see it. This is what aborted
|
||||
/// the P0 bench APK on Iris's phone with only "wgpu error: Validation
|
||||
/// Error" surviving into the crash report (RUST.md's P0 box, "iris
|
||||
/// bench crash on the phone, 2026-09-06"): `create_bind_group_layout`
|
||||
/// validates against *this* adapter's downlevel capabilities and
|
||||
/// limits, which a desktop GPU and the emulator's software renderers
|
||||
/// never exercised. The caller (`android::view::IrisViewPeer::
|
||||
/// surface_changed`) logs this one-line-flattened and shows it on
|
||||
/// screen instead of aborting the process.
|
||||
pub fn new(
|
||||
window: NativeWindow,
|
||||
width: u32,
|
||||
height: u32,
|
||||
content_scale: f32,
|
||||
) -> Result<Self, String> {
|
||||
// `force-gles` (RUST.md's I5 "Where iris's frame time goes") pins
|
||||
// the build to GLES, to isolate whether the backend itself explains
|
||||
// the frame time gap against Compose. `cfg!` rather than a runtime
|
||||
// switch: there is no way to hand an env var to an already-launched
|
||||
// Android process on this machine (see the feature's doc in
|
||||
// Cargo.toml).
|
||||
//
|
||||
// Otherwise: **Vulkan where it has an adapter at all, GLES where it
|
||||
// has none.** `Backends::PRIMARY` leaves `GL` out, so a device
|
||||
// offering only a GLES adapter had no adapter at all and this
|
||||
// function aborted the process -- this checkout's emulator, whose
|
||||
// Vulkan ICD carries no adapter behind it (`NotFound {
|
||||
// active_backends: VULKAN, no_adapter_backends: VULKAN,
|
||||
// supported_backends: VULKAN | GL }`), and the crash loop in
|
||||
// RUST.md's queue.
|
||||
//
|
||||
// The choice is made *before any surface exists*, with an instance
|
||||
// that never touches the window, because **an Android window can be
|
||||
// connected to one graphics API only**. One instance carrying both
|
||||
@@ -182,10 +142,6 @@ impl AndroidRenderer {
|
||||
.block_on()
|
||||
.map_err(|error| format!("No usable GPU adapter for backends {backends:?}: {error}"))?;
|
||||
|
||||
// Same request as the winit backend's `UiRenderer::new` -- no
|
||||
// binding-array features, see TEXTURES.md's "Recommended shape".
|
||||
// `iris_core::device_limits()` is shared between the two backends;
|
||||
// see its own doc for why it is not simply `Limits::default()`.
|
||||
let (device, queue) = adapter
|
||||
.request_device(&DeviceDescriptor {
|
||||
required_limits: iris_core::device_limits(),
|
||||
@@ -200,14 +156,6 @@ impl AndroidRenderer {
|
||||
)
|
||||
})?;
|
||||
|
||||
// wgpu's default handler for an error raised outside `UiRenderNode::
|
||||
// new`'s own error scopes (i.e. everything past device creation --
|
||||
// an ordinary frame's `update`/`draw`) is `panic!`, unconditionally,
|
||||
// with no caller able to intervene: the same mechanism that aborted
|
||||
// the P0 bench APK once already, just at a different call site. Log
|
||||
// and record instead of letting that default stand -- RUST.md's P0
|
||||
// box, "every wgpu uncaptured error ... it must never panic in
|
||||
// release".
|
||||
let wgpu_errors = iris_core::WgpuErrorLog::default();
|
||||
let wgpu_errors_for_handler = wgpu_errors.clone();
|
||||
device.on_uncaptured_error(std::sync::Arc::new(move |error| {
|
||||
@@ -263,12 +211,6 @@ impl AndroidRenderer {
|
||||
surface.configure(&device, &config);
|
||||
|
||||
let encoder = Self::create_encoder(&device);
|
||||
// Physical pixels, matching the swapchain's own `width`/`height`
|
||||
// exactly -- see `android::view::AndroidUiState::content_scale`'s
|
||||
// field comment for why this is no longer divided into a separate
|
||||
// logical space (that stopgap is what made text blurry, RUST.md's
|
||||
// P0 box). `Len::dp` folds the density in at layout time instead,
|
||||
// so nothing here needs to know it at all.
|
||||
let window_size = iris_core::util::Vec2::new(width as f32, height as f32);
|
||||
let ui = match UiRenderNode::new(&device, &queue, &config, window_size) {
|
||||
Ok(ui) => ui,
|
||||
@@ -333,16 +275,6 @@ impl AndroidRenderer {
|
||||
)
|
||||
}
|
||||
|
||||
/// The Diagnostics page's whole report: adapter identity, font
|
||||
/// resolution, the atlas's own view count, every uncaptured wgpu error
|
||||
/// so far, and the frame report -- RUST.md's P0 box, "a named
|
||||
/// `Diagnostics` control ... adapter info, limits, fonts found, atlas
|
||||
/// format/pages, wgpu errors so far, frame report". One string rather
|
||||
/// than a struct the caller formats, since the only consumer is a
|
||||
/// plain `TextView` with a "copy this and send it to Iris" affordance,
|
||||
/// the same shape `surface_changed`'s crash report already uses
|
||||
/// (UI_RULES.md: a failure -- or here, a state worth reporting --
|
||||
/// carries enough to act on where it's shown).
|
||||
pub fn diagnostics_report(
|
||||
&self,
|
||||
font: &iris_core::FontDiagnostics,
|
||||
@@ -388,14 +320,6 @@ impl AndroidRenderer {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns what changed this frame -- see `FrameDiagnostics`'s doc
|
||||
/// comment for why two of its four fields describe the *previous*
|
||||
/// frame rather than this one. `IrisViewPeer::render` logs this for
|
||||
/// the first `DIAGNOSTIC_FRAMES` frames after each `surface_changed`,
|
||||
/// per RUST.md's P0 box ("the first input frame" investigation): the
|
||||
/// glyph-wipe Iris reported happens on the first tap or scroll after a
|
||||
/// fresh surface, so that is exactly the window a report needs to
|
||||
/// cover, not an arbitrary slice of a long session.
|
||||
pub fn update(&mut self, ui: &mut UiData, render: &mut UiRenderState) -> FrameDiagnostics {
|
||||
let atlas_pages_grown_prev = self.ui.take_atlas_pages_grown();
|
||||
let image_bind_group_creates_prev = self.ui.take_image_bind_group_creates();
|
||||
@@ -409,30 +333,10 @@ impl AndroidRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames drawn on this surface so far -- see `frame_count`'s field
|
||||
/// comment.
|
||||
pub fn frame_count(&self) -> u64 {
|
||||
self.frame_count
|
||||
}
|
||||
|
||||
/// Draws and presents one frame, returning the two parts of it that
|
||||
/// are waits rather than work: the `get_current_texture` acquire and
|
||||
/// `queue.submit` + `present()`. The caller
|
||||
/// (`android::view::render`) times the whole frame and fills in
|
||||
/// `FrameParts::total`, so whatever is left over is iris's own work.
|
||||
///
|
||||
/// **The acquire is why this returns two numbers and not one.**
|
||||
/// `get_current_texture` blocks until the compositor hands back a
|
||||
/// swapchain image, which on an app comfortably ahead of the display
|
||||
/// is most of every frame -- so counting it as CPU work (which this
|
||||
/// did until 2026-09-09) reports a fling as milliseconds of iris
|
||||
/// being slow when they are milliseconds of iris waiting its turn.
|
||||
///
|
||||
/// RUST.md's I5 "Where iris's frame time goes" diagnosis, added
|
||||
/// 2026-09-05 -- see `iris_core::FrameParts`'s own doc for the caveat
|
||||
/// the submit half shares: `present()` is not fenced against the GPU
|
||||
/// actually finishing, so it is "how long the CPU was blocked handing
|
||||
/// the frame off", not confirmed GPU time.
|
||||
pub fn draw(&mut self) -> FrameParts {
|
||||
let acquire_start = Instant::now();
|
||||
let output = match self.surface.get_current_texture() {
|
||||
@@ -499,20 +403,6 @@ impl AndroidRenderer {
|
||||
/// tokio thread `Tasks::init` spawned, which is not attached to the JVM, so
|
||||
/// asking for a frame means attaching first. The global ref is what
|
||||
/// survives past the JNI call that handed the `View` to us.
|
||||
///
|
||||
/// **Goes through `View::post_delayed`, not `post_frame_callback`
|
||||
/// directly** -- found the hard way (RUST.md's I5 Android integration):
|
||||
/// `post_frame_callback`'s Java side calls `Choreographer.getInstance()`,
|
||||
/// which throws `IllegalStateException` unless the *calling* thread already
|
||||
/// has a `Looper` (`Choreographer.getInstance()`'s own contract). A tokio
|
||||
/// worker thread, even freshly attached to the JVM, has none -- the crash
|
||||
/// was a `JavaException` inside `View::post_frame_callback`'s `.unwrap()`,
|
||||
/// aborting the process on the second `redraw.request_redraw()` any
|
||||
/// android transcript-screen fetch made. `View.postDelayed(Runnable, 0)`
|
||||
/// is the ordinary Android answer to "queue work onto a View's own UI
|
||||
/// thread from any thread" and needs no Looper of its own; `delayed_callback`
|
||||
/// below is what that Runnable resolves to on the UI thread, where a real
|
||||
/// `post_frame_callback` is safe again.
|
||||
pub struct AndroidRedrawHandle {
|
||||
vm: JavaVM,
|
||||
view: GlobalRef,
|
||||
|
||||
@@ -11,10 +11,6 @@ use android_view::{
|
||||
},
|
||||
ndk::event::{Axis, Keycode, MotionAction},
|
||||
};
|
||||
// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the
|
||||
// `Sized` *widget* (`widget::position::sized::Sized`), and an unqualified
|
||||
// glob import shadows the language prelude -- `default/mod.rs` has the same
|
||||
// explicit import for the same reason.
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
marker::{PhantomData, Sized},
|
||||
@@ -62,43 +58,9 @@ pub struct AndroidUiState {
|
||||
/// path -- see `android/insets.rs` for why they need a registry of
|
||||
/// their own.
|
||||
shared: Rc<RefCell<Shared>>,
|
||||
/// I4 (RUST.md): pushed from `IrisViewPeer::render` and consulted by
|
||||
/// the `AccessibilityNodeProvider` impl below; see `android/access.rs`
|
||||
/// for the abort mitigation every `raise` on it goes through.
|
||||
pub access_adapter: AccessAdapter,
|
||||
/// The AccessKit tree itself -- see `iris_core::AccessTree`'s doc
|
||||
/// comment.
|
||||
pub access: AccessTree,
|
||||
/// iris's own frame-time report (RUST.md's I5 box, "Measurements
|
||||
/// taken" (b)) -- `render()` below records into it once per frame,
|
||||
/// because `dumpsys gfxinfo` cannot see a `SurfaceView`'s own
|
||||
/// GPU-drawn frames at all. See `iris_core::FrameReport`'s own doc.
|
||||
pub frame_report: FrameReport,
|
||||
/// `DisplayMetrics.density` (`new_peer`'s doc comment): physical pixels
|
||||
/// per dp on this device, read once at view construction and carried
|
||||
/// on `UiRenderState::density` (`render.set_density`, `new_peer`) from
|
||||
/// then on -- every `Len::dp` in the widget tree resolves against it at
|
||||
/// layout time (`Len::dp`'s field doc, IRIS_TODO.md's
|
||||
/// "density-independent length unit" item, 2026-09-06).
|
||||
///
|
||||
/// **Everything else in this module is physical pixels, matching the
|
||||
/// real wgpu surface/swapchain resolution** -- window size, touch
|
||||
/// coordinates, insets. That is a correction from an earlier version
|
||||
/// of this comment, which had `window_size`/`surface_changed`'s
|
||||
/// `UiRenderState::resize` call divide by `content_scale` into a
|
||||
/// *logical* coordinate space instead, as a global stopgap for
|
||||
/// RUST.md's P0 box's phone report ("text is far too small"). That
|
||||
/// stopgap fixed the size but not the *sharpness*: dividing to logical
|
||||
/// units meant a `16.0`-sized glyph rasterised at 16 physical px and
|
||||
/// then implicitly upscaled ~3x by the NDC mapping onto the real
|
||||
/// physical framebuffer -- the exact "blurry ... glyphs drawn at
|
||||
/// logical size and stretched by the scale" Iris reported next.
|
||||
/// Resolving `dp` at layout time replaces it: a widget author writes
|
||||
/// `dp(16)` for a size that should look the same physical size on any
|
||||
/// density, and everything downstream (layout, hit-testing, the window
|
||||
/// uniform, and the font size handed to the text shaper) works in the
|
||||
/// display's own physical pixels throughout, so nothing is
|
||||
/// rasterised at one resolution and displayed at another.
|
||||
pub content_scale: f32,
|
||||
/// The last insets `render()` saw -- compared each frame so
|
||||
/// `AndroidAppState::on_insets_changed` fires only when they actually
|
||||
@@ -131,13 +93,6 @@ impl AndroidUiState {
|
||||
self.shared.borrow().insets
|
||||
}
|
||||
|
||||
/// The insets state as one line for a diagnostics pane, including how
|
||||
/// many times the platform has delivered any -- see
|
||||
/// `insets::Shared::updates` for why the count is the load-bearing
|
||||
/// part. `dispatches=0` says the listener has never run and the
|
||||
/// numbers beside it are defaults rather than measurements, which is
|
||||
/// the distinction a screenshot otherwise cannot make (UI_RULES.md,
|
||||
/// "design the unknown state first").
|
||||
pub fn insets_report(&self) -> String {
|
||||
let shared = self.shared.borrow();
|
||||
let i = shared.insets;
|
||||
@@ -167,39 +122,12 @@ pub trait HasAndroidUiState: Sized + 'static {
|
||||
|
||||
pub trait AndroidAppState: HasAndroidUiState {
|
||||
fn new(ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self;
|
||||
/// The system back gesture/button. `true` means handled -- nothing
|
||||
/// further happens; `false` lets the activity finish as it would with
|
||||
/// no view at all. The default declines, since most screens have
|
||||
/// nothing to intercept it for.
|
||||
#[allow(unused_variables)]
|
||||
fn back_pressed(&mut self, rsc: &mut AndroidRsc<Self>, render: &mut UiRenderState) -> bool {
|
||||
false
|
||||
}
|
||||
/// Called once, right after `new`, with a fresh `JavaVM` handle and a
|
||||
/// global reference to this app's own `View` -- for a caller that
|
||||
/// needs to call into Java itself beyond what a [`RequestRedraw`]
|
||||
/// handle already covers (P0's bench build calling
|
||||
/// `BatteryManager`/`ClipboardManager` through the view's `Context`,
|
||||
/// docs/RUST.md). Not folded into `new` itself: most implementors need
|
||||
/// nothing here, and `new`'s job is building the widget tree, not
|
||||
/// holding a platform handle -- the default does nothing. `vm`/`view`
|
||||
/// are independent handles from the ones `new_peer` keeps for its own
|
||||
/// `RequestRedraw` (a fresh `get_java_vm`/`new_global_ref` each), so
|
||||
/// storing them has no effect on that mechanism.
|
||||
#[allow(unused_variables)]
|
||||
fn platform_ready(&mut self, rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {}
|
||||
/// Called from `render()` whenever `AndroidUiState::insets()` differs
|
||||
/// from what it was last frame -- once at startup for the status bar
|
||||
/// (RUST.md's P0 box: "the status-bar inset is not applied" reported
|
||||
/// the two top buttons sitting under it, because nothing read `.top`
|
||||
/// at all), and again on a rotation or the keyboard opening/closing.
|
||||
/// `insets` is in the same physical-pixel units everything else in the
|
||||
/// tree now uses (`AndroidUiState::content_scale`'s field comment), so
|
||||
/// a widget can add it to a layout size directly -- `dp(...) +
|
||||
/// abs(insets.top)` if the widget wants a density-independent size
|
||||
/// plus the system bar's own (already-physical) height. The default
|
||||
/// does nothing -- most screens have no chrome that sits under a
|
||||
/// system bar.
|
||||
#[allow(unused_variables)]
|
||||
fn on_insets_changed(&mut self, rsc: &mut AndroidRsc<Self>, insets: WindowInsets) {}
|
||||
}
|
||||
@@ -238,11 +166,6 @@ impl WindowInsets {
|
||||
}
|
||||
}
|
||||
|
||||
/// The android-view analogue of `default::DefaultRsc` -- identical in
|
||||
/// substance, since none of `UiRsc`/`HasEvents`/`HasTasks`/`HasWidgetState`
|
||||
/// mention winit. Kept as a separate type rather than shared code because
|
||||
/// the two backends' `ViewPeer`/`ApplicationHandler` entry points hold
|
||||
/// their harness state differently (see RUST.md's I2).
|
||||
pub struct AndroidRsc<State: 'static> {
|
||||
pub ui: UiData,
|
||||
pub events: EventManager<Self>,
|
||||
@@ -379,11 +302,6 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
pub(super) fn after_input(&mut self, ctx: &mut CallbackCtx) {
|
||||
self.run_input_frame(ctx);
|
||||
|
||||
// RUST.md's P0 box, "doesn't enter it until I hit space, and also
|
||||
// doesn't move cursor forward": Gboard needs `updateSelection`
|
||||
// after every edit to keep its own model of the field in sync, or
|
||||
// it holds keystrokes back rather than trusting a screen it
|
||||
// believes is stale. See `update_ime_selection`'s own doc.
|
||||
self.update_ime_selection(ctx);
|
||||
|
||||
let ui_state = self.state.android_state_mut();
|
||||
@@ -393,16 +311,6 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The view's one clock, anchoring it on `nanos` if nothing has yet
|
||||
/// -- see the `device_clock` field. Either source may be the first to
|
||||
/// arrive: a frame callback fires before any touch on an app that
|
||||
/// animates at startup, and a touch arrives first on one that does
|
||||
/// not.
|
||||
///
|
||||
/// `oldest` is the earliest sample the anchoring event carries, which
|
||||
/// matters only when this is the call that anchors -- see
|
||||
/// `DeviceClock::anchored`. A frame time carries no batch, so it
|
||||
/// passes its own time for both.
|
||||
fn device_clock(&mut self, event_time: i64, oldest: i64) -> DeviceClock {
|
||||
*self
|
||||
.device_clock
|
||||
@@ -417,20 +325,6 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `log::debug!` calls here are a live diagnostic for a still-open
|
||||
/// finding (RUST.md's I2): layout runs and reports the right pixel
|
||||
/// region for the root (confirmed via `window_region`, logged below),
|
||||
/// and the clear colour reaches the screen (confirmed by swapping it to
|
||||
/// magenta and screenshotting), but no primitive ever appears on top of
|
||||
/// it -- on both the Vulkan/SwiftShader and GLES/virgl backends. Leave
|
||||
/// these in until that is root-caused; removing them loses the exact
|
||||
/// evidence a `logcat` capture needs to reproduce the state. Gated on
|
||||
/// `iris::diagnostics::trace_enabled` since 2026-09-07 (docs/RUST.md's
|
||||
/// review, D1): unconditional, they were two `debug!` lines every
|
||||
/// rendered frame, and `client_core::log_ring`'s `RingLogger` records
|
||||
/// every level the app's already-`Debug` install lets through
|
||||
/// regardless of target, so they filled the whole ring in under ten
|
||||
/// seconds at 120Hz and left `Copy report` nothing else to show.
|
||||
fn render(&mut self, ctx: &mut CallbackCtx, now: Instant) {
|
||||
if self.state.android_state().renderer.is_none() {
|
||||
return;
|
||||
@@ -444,12 +338,6 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
let current_insets = ui_state.insets();
|
||||
if current_insets != ui_state.last_insets {
|
||||
let physical = WindowInsets::from_physical(current_insets);
|
||||
// One line per real insets change. Iris's phone is the only
|
||||
// place several of these bugs reproduce and `adb logcat` is
|
||||
// the only instrument there (this-machine-android: system
|
||||
// tracing is broken on that device), so the numbers a layout
|
||||
// is actually fed have to reach the log -- "the composer
|
||||
// floats at launch" is unanswerable from a screenshot alone.
|
||||
log::info!(
|
||||
"iris insets: left={} top={} right={} bottom={} ime_bottom={} \
|
||||
ime_visible={} window={:?}",
|
||||
@@ -465,14 +353,6 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
self.state.on_insets_changed(&mut self.rsc, physical);
|
||||
}
|
||||
|
||||
// Gated the same way `iris::frame`'s own line is (docs/RUST.md's
|
||||
// "Phone logging" review, D1): a bare `log::debug!` reaches
|
||||
// `client_core::log_ring`'s ring regardless of level, since
|
||||
// `RingLogger::enabled` is unconditionally `true` and the app
|
||||
// installs at `LevelFilter::Debug` -- two of these a rendered
|
||||
// frame filled the 2000-line ring in under ten seconds at 120Hz,
|
||||
// leaving `Copy report` nothing but frame spam. See
|
||||
// `iris::diagnostics`'s module doc.
|
||||
if crate::diagnostics::trace_enabled() {
|
||||
let ui_state = self.state.android_state();
|
||||
log::debug!(
|
||||
@@ -488,35 +368,8 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
self.window_size(),
|
||||
);
|
||||
}
|
||||
// iris's own frame-time report (RUST.md's I5 box, "Measurements
|
||||
// taken" (b)): started here, at the same point a redraw request
|
||||
// fires, and stopped after `renderer.draw()`'s `queue.submit` +
|
||||
// `present()` -- the span Compose's render report and `gfxinfo`
|
||||
// both count. See `iris_core::FrameReport`'s own doc for exactly
|
||||
// what this does and does not measure.
|
||||
let frame_start = Instant::now();
|
||||
// Anything moving on its own -- today a `LazySpan` coasting
|
||||
// through a fling -- is advanced here, before the draw. **On
|
||||
// `now`, not on `frame_start`**: `now` is the vsync the
|
||||
// `Choreographer` handed this callback, which is evenly spaced,
|
||||
// while `frame_start` is whenever the callback actually got to
|
||||
// run. The frames are *presented* on the even cadence either way,
|
||||
// so sampling the animation on the uneven one moves the content by
|
||||
// an uneven distance per frame -- a fling that shimmers with no
|
||||
// frame late enough to show up in a report. See
|
||||
// `UiData::tick_animations` and `sense::DeviceClock`;
|
||||
// `default/mod.rs`'s `RedrawRequested` arm is the winit half.
|
||||
let animating = self.rsc.ui.tick_animations(now);
|
||||
// **Asked for before the work, not after it.** A frame callback is
|
||||
// one-shot, so an animation that wants another frame has to say so
|
||||
// every frame -- and `Choreographer.postFrameCallback` schedules
|
||||
// for the next vsync *after the call*. Asking at the end of this
|
||||
// function meant any frame whose work ran past the vsync boundary
|
||||
// (the swapchain acquire below alone can sit most of a frame)
|
||||
// registered too late for the next one and got the one after --
|
||||
// so one frame over budget silently cost a second frame as well.
|
||||
// Unlike `after_input`, which only has to ask when input dirtied
|
||||
// something.
|
||||
if animating {
|
||||
ctx.view.post_frame_callback(&mut ctx.env);
|
||||
}
|
||||
@@ -527,14 +380,6 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
return;
|
||||
};
|
||||
let frame_diagnostics = renderer.update(&mut self.rsc.ui, &mut self.render);
|
||||
// First `DIAGNOSTIC_FRAMES` frames after each `surface_changed`
|
||||
// only -- RUST.md's P0 box, "the first input frame" investigation:
|
||||
// the glyph-wipe Iris reported happens on the first tap or scroll
|
||||
// after a fresh surface, so a report from that window is what
|
||||
// would show whether an atlas grow, a masks/move_offsets resize, or
|
||||
// a fresh wgpu error coincided with it. `frame_count()` was just
|
||||
// incremented inside `update()`, so `<=` counts frame 1 through
|
||||
// `DIAGNOSTIC_FRAMES` inclusive.
|
||||
if renderer.frame_count() <= DIAGNOSTIC_FRAMES {
|
||||
log::info!(
|
||||
"iris frame diagnostics: frame={} masks_resized={} moves_resized={} \
|
||||
@@ -549,9 +394,6 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
}
|
||||
let mut parts = renderer.draw();
|
||||
parts.total = frame_start.elapsed();
|
||||
// Dated on `now` -- the vsync this frame was for -- so the gap
|
||||
// between consecutive frames is the display's own cadence and
|
||||
// `PhaseStats::missed` counts vsyncs nothing was drawn for.
|
||||
self.state
|
||||
.android_state_mut()
|
||||
.frame_report
|
||||
@@ -570,12 +412,6 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
);
|
||||
}
|
||||
|
||||
// I4 (RUST.md): only produces a `TreeUpdate` -- and so only queues
|
||||
// anything to raise -- when the named set actually changed this
|
||||
// frame; see `AccessTree`'s doc comment. Deferred rather than
|
||||
// raised inline so it runs after this callback releases whatever
|
||||
// it's holding, matching android-view's own demo and `raise`'s own
|
||||
// contract.
|
||||
let ui_state = self.state.android_state_mut();
|
||||
if let Some(tree_update) =
|
||||
ui_state
|
||||
@@ -597,19 +433,6 @@ fn show_soft_input<'local>(env: &mut JNIEnv<'local>, view: &View<'local>) {
|
||||
imm.show_soft_input(env, view, 0);
|
||||
}
|
||||
|
||||
/// Replaces the activity's content with a plain, selectable, scrollable
|
||||
/// text view holding `report` -- the on-screen half of `surface_changed`'s
|
||||
/// renderer-failure path (UI_RULES.md: "a failure is reported where it
|
||||
/// happened, and says what to do next," here "copy this and send it").
|
||||
/// Goes through an ordinary instance method on the Java side
|
||||
/// (`IrisView.showRendererError`) rather than a new `native` method: this
|
||||
/// call is Rust reaching *into* Java, the opposite direction from every
|
||||
/// `native fn` android-view/`IrisView` declare, and an ordinary virtual
|
||||
/// call resolves against `ctx.view`'s real runtime class (`IrisView`) the
|
||||
/// same way any other JNI method call here does. Silently does nothing on
|
||||
/// any JNI failure -- there is no more-fallback screen to fall back to,
|
||||
/// and the `log::error!` in `surface_changed` already reached logcat
|
||||
/// first.
|
||||
fn show_renderer_error<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, report: &str) {
|
||||
let Ok(message) = env.new_string(report) else {
|
||||
return;
|
||||
@@ -703,18 +526,6 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
let trace_input = crate::diagnostics::trace_enabled();
|
||||
let mut historical_ms: Vec<(u64, f32, f32)> = Vec::new();
|
||||
|
||||
// **Historical samples first.** A flick on a 120Hz screen is
|
||||
// delivered as one or two `MotionEvent`s with the intermediate
|
||||
// positions batched inside them, so reading only `x()`/`y()` threw
|
||||
// away every sample but the last: the velocity tracker saw one
|
||||
// `Pan` for the whole gesture, `VelocityTracker::velocity` answers
|
||||
// 0.0 below two samples, and the release therefore flung at zero --
|
||||
// Iris's phone, twice ("fling still doesn't work"), while a
|
||||
// `ui-trace` swipe, which is many evenly-spaced events, flung fine.
|
||||
// Replayed one at a time through the sensors rather than summarised,
|
||||
// so the arbiter, the tracker and any other sensor all see the same
|
||||
// motion the finger actually made; only the last sample ends the
|
||||
// frame (`after_input`).
|
||||
if matches!(action, MotionAction::Move) {
|
||||
// Android documents the historical samples as oldest first and
|
||||
// the event's own sample as the newest of the batch; everything
|
||||
@@ -757,15 +568,6 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
ui_state.cursor.pos = vec2(x, y);
|
||||
ui_state.cursor.buttons.left.update(false);
|
||||
}
|
||||
// A cancel ends the press -- a release that never arrives
|
||||
// leaves whichever widget took pointer capture holding it
|
||||
// forever -- but it is **not** a release, and saying so is
|
||||
// `CursorState::cancelled`. It used to take the `Up` arm, so
|
||||
// the system's own swipe up from the bottom edge to leave the
|
||||
// app (moves, then `ACTION_CANCEL`) reached iris as a flick
|
||||
// released at speed, and the transcript flung while the app
|
||||
// was in the background: Iris's 2026-09-08 "leaving and
|
||||
// reopening the app also randomly moved the vertical scroll".
|
||||
MotionAction::Cancel => {
|
||||
ui_state.cursor.pos = vec2(x, y);
|
||||
ui_state.cursor.buttons.left.update(false);
|
||||
@@ -837,25 +639,6 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
// in particular) mean something different from a `rest`-based one.
|
||||
self.render.resize((width as f32, height as f32));
|
||||
|
||||
// **Reuse the existing renderer (device, atlas, buffers, bind
|
||||
// groups) when one is already live -- only reconfigure the
|
||||
// surface.** `surfaceChanged` fires on *every* size or format
|
||||
// change, not only on a genuinely new `Surface`/window: showing
|
||||
// the IME under `adjustResize` resizes the same `SurfaceView` and
|
||||
// is reported through this exact callback. Rebuilding the whole
|
||||
// `AndroidRenderer` here used to mean a fresh `UiRenderNode::new`
|
||||
// -- a brand-new, empty glyph atlas and fresh GPU buffers -- while
|
||||
// `iris_core`'s CPU-side glyph cache (`primitive/text.rs`) kept the
|
||||
// atlas coordinates it had already handed out against the *old*
|
||||
// atlas. Every glyph then drew from a UV rectangle that pointed
|
||||
// into a texture that had just been recreated empty, so text
|
||||
// vanished on the first keyboard open while rects (which never go
|
||||
// through the atlas) kept drawing -- exactly the "rectangles stay,
|
||||
// glyphs disappear" Iris reported. Confirmed by reading this path
|
||||
// end to end (no fresh-atlas rebuild anywhere in `resize()` below,
|
||||
// only in `AndroidRenderer::new`) before changing anything, per
|
||||
// AGENTS.md's "verify before finishing".
|
||||
//
|
||||
// `AndroidRenderer::resize` only reconfigures the wgpu surface and
|
||||
// rewrites the window uniform -- device, atlas, buffers and bind
|
||||
// groups are untouched, so the glyph cache's coordinates stay
|
||||
@@ -881,17 +664,6 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
}
|
||||
|
||||
let window = holder.surface(&mut ctx.env).to_native_window(&mut ctx.env);
|
||||
// `AndroidRenderer::new` used to panic here through wgpu's own
|
||||
// default uncaptured-error handler on a bind-group-layout
|
||||
// validation failure -- exactly what aborted the P0 bench APK on
|
||||
// Iris's phone with the message truncated to "wgpu error:
|
||||
// Validation Error" and nothing else recoverable from the crash
|
||||
// report (RUST.md's P0 box, "iris bench crash on the phone,
|
||||
// 2026-09-06"). It now returns the full diagnostic instead; this is
|
||||
// the one place in the app that can turn it into something a
|
||||
// person can read, since `ctx.view`/`ctx.env` (needed to reach the
|
||||
// Java side) are only in scope inside a `ViewPeer` callback.
|
||||
//
|
||||
// `content_scale` reaches `AndroidRenderer` only for the
|
||||
// Diagnostics page's report text now -- window size and the
|
||||
// shader's window uniform are physical pixels throughout (see the
|
||||
@@ -930,24 +702,7 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
self.render(ctx, Instant::now());
|
||||
}
|
||||
Err(report) => {
|
||||
// One line for logcat (UI_RULES.md: "the full text for
|
||||
// whoever can read the log" lives here), the multi-line
|
||||
// original on screen -- `show_renderer_error` below.
|
||||
log::error!("iris renderer init failed: {}", report.replace('\n', " | "));
|
||||
// Deferred, not called directly: `Activity::setContentView`
|
||||
// tears the old view hierarchy down synchronously, which
|
||||
// fires `IrisView`'s own `onFocusChanged` before
|
||||
// `setContentView` returns -- straight back into this same
|
||||
// `IrisViewPeer` through `on_focus_changed` while
|
||||
// `with_peer` (android-view's dispatch, `view.rs` upstream)
|
||||
// still holds this peer's `RefCell` borrow for the
|
||||
// `surface_changed` call in progress. Found by inducing a
|
||||
// validation error and hitting `RefCell already borrowed`
|
||||
// at exactly that reentrant call (RUST.md's P0 box).
|
||||
// `push_dynamic_deferred_callback` runs after `with_peer`
|
||||
// drops the borrow, which is what every other callback in
|
||||
// this file that reaches into Java already relies on
|
||||
// (`raise_if_enabled`, above).
|
||||
ctx.push_dynamic_deferred_callback(move |env, view| {
|
||||
show_renderer_error(env, view, &report);
|
||||
});
|
||||
@@ -996,8 +751,6 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
/// one.
|
||||
fn delayed_callback(&mut self, ctx: &mut CallbackCtx) {
|
||||
self.drain_tasks();
|
||||
// No vsync to date this one on -- it is a background task's
|
||||
// "there is new state", not a frame the display asked for.
|
||||
self.render(ctx, Instant::now());
|
||||
}
|
||||
|
||||
@@ -1106,8 +859,6 @@ pub fn new_peer<'local, State: AndroidAppState>(
|
||||
state: Default::default(),
|
||||
_state: PhantomData,
|
||||
};
|
||||
// See `TextData::density`'s field doc for why this is set alongside
|
||||
// `render.set_density` below rather than read from there.
|
||||
rsc.ui.text.density = content_scale;
|
||||
let shared = Rc::new(RefCell::new(Shared::default()));
|
||||
let ui_state = AndroidUiState::new(shared.clone(), content_scale);
|
||||
@@ -1116,8 +867,6 @@ pub fn new_peer<'local, State: AndroidAppState>(
|
||||
let platform_view = env.new_global_ref(&view.0).unwrap();
|
||||
state.platform_ready(&mut rsc, platform_vm, platform_view);
|
||||
let mut render = UiRenderState::new();
|
||||
// Every `Len::dp` in the tree resolves against this from now on -- see
|
||||
// `UiRenderState::density`'s field doc and `Len::dp`'s.
|
||||
render.set_density(content_scale);
|
||||
let peer = IrisViewPeer {
|
||||
rsc,
|
||||
|
||||
-53
@@ -1,21 +1,7 @@
|
||||
use crate::prelude::*;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// What focusing a text field takes from whichever backend is running --
|
||||
/// tracked here rather than duplicated per backend, since `Selector` and
|
||||
/// `Selectable` (below) are the *only* thing that decides which `TextEdit`
|
||||
/// is the IME's target, and both platforms need the same double-click
|
||||
/// timing and the same "remember which one" bookkeeping. What differs is
|
||||
/// what happens *after* the focus record is set: winit tells the
|
||||
/// compositor an IME area (`focus_gained`, in `default/attr.rs`); on
|
||||
/// android-view a keyboard has to be asked for explicitly, and only from a
|
||||
/// JNI call this crate cannot make outside a view callback -- so
|
||||
/// `focus_gained` there (`android/attr.rs`) just raises a flag the next
|
||||
/// touch callback consumes. See RUST.md's I2.
|
||||
pub trait FocusHost {
|
||||
/// True on a click close enough in time to the previous one to grow a
|
||||
/// selection instead of starting a new one, updating the clock as a
|
||||
/// side effect the way a real double-click timer does.
|
||||
fn recent_click(&mut self) -> bool;
|
||||
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>);
|
||||
/// Called on every tap that should put the IME on `id`: the tap that
|
||||
@@ -27,17 +13,9 @@ pub trait FocusHost {
|
||||
/// lets the repeat tap be handled by the same call rather than by a
|
||||
/// second "re-show" entry point beside it.
|
||||
fn focus_gained(&mut self, region: Option<PixelRegion>);
|
||||
/// Whether `id` is the current focus target -- what [`select`] uses to
|
||||
/// tell a fresh press (which must wait to see whether it becomes a tap
|
||||
/// or a drag before focusing/showing the IME, Iris 2026-09-06: "if I
|
||||
/// swipe over the input bar it brings up the keyboard") from a drag
|
||||
/// continuing inside a field that was already focused (an ordinary
|
||||
/// drag-to-select, unaffected).
|
||||
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool;
|
||||
}
|
||||
|
||||
/// Helper shared by every `FocusHost` impl, so the double-click window is
|
||||
/// one constant rather than one per backend.
|
||||
pub fn recent_click(last_click: &mut Instant) -> bool {
|
||||
let now = Instant::now();
|
||||
let recent = (now - *last_click) < Duration::from_millis(300);
|
||||
@@ -45,18 +23,6 @@ pub fn recent_click(last_click: &mut Instant) -> bool {
|
||||
recent
|
||||
}
|
||||
|
||||
/// `PressStart`/`Pressing`/`PressEnd`, all for the left button -- what
|
||||
/// [`Selector`]/[`Selectable`] register instead of [`CursorSense::
|
||||
/// click_or_drag`], so their shared handler (`on_press`, below) sees every
|
||||
/// frame of a gesture and can tell a completed tap from a drag itself,
|
||||
/// rather than reacting to `PressStart` alone the way `click_or_drag`'s
|
||||
/// consumer used to (Iris, 2026-09-06: "if I swipe over the input bar it
|
||||
/// brings up the keyboard").
|
||||
/// `CursorSense::Cancel` is in the set for the same reason `DragGesture`
|
||||
/// registers it: if a scroll area or a list takes the pointer mid-gesture,
|
||||
/// this field sees no `PressEnd`, and a `press_origin` left set is then
|
||||
/// compared against the *next* press -- a stray selection, or a keyboard
|
||||
/// summoned by a tap somewhere else entirely.
|
||||
fn press_track() -> CursorSenses {
|
||||
CursorSense::click()
|
||||
| CursorSense::Pressing(CursorButton::Left)
|
||||
@@ -173,22 +139,6 @@ fn on_press(
|
||||
ctx.text.press_origin = None;
|
||||
}
|
||||
ctx.select(pos, size, true, false);
|
||||
// A tap on a field that is *already* focused asks for the
|
||||
// keyboard again (Iris's phone, 2026-09-06: "I can't reopen
|
||||
// keyboard by tapping on message box after it already
|
||||
// happened once"). Dismissing the IME -- back gesture, or
|
||||
// its own hide button -- takes the keyboard away but leaves
|
||||
// the field focused, so without this the one branch that
|
||||
// requests it (the unfocused one below) never runs again
|
||||
// and the field is permanently unable to summon it.
|
||||
// Android's own `EditText` does exactly this: every tap on
|
||||
// a focused field calls `showSoftInput`, which is a no-op
|
||||
// when the keyboard is already up.
|
||||
//
|
||||
// Gated on the same tap-vs-drag test the unfocused branch
|
||||
// uses, not on `PressEnd` alone, so a drag-to-select that
|
||||
// happens to finish inside the field does not summon a
|
||||
// keyboard the reader was not asking for.
|
||||
if ended && dx.abs() <= DRAG_SLOP && dy.abs() <= DRAG_SLOP {
|
||||
state.focus_gained(render.window_region(&id, &*rsc));
|
||||
}
|
||||
@@ -208,9 +158,6 @@ fn on_press(
|
||||
if let Some(origin) = ctx.text.press_origin
|
||||
&& ((pos.x - origin.x).abs() > DRAG_SLOP || (pos.y - origin.y).abs() > DRAG_SLOP)
|
||||
{
|
||||
// Past the slop before release: this is a drag, not a tap
|
||||
// -- give up the pending focus rather than granting it once
|
||||
// the finger lifts wherever it happens to be by then.
|
||||
ctx.text.press_origin = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
//! I4 (RUST.md): the desktop half of the AccessKit push, over
|
||||
//! `accesskit_winit`. `bench-lib.sh`'s tap-by-name goes through the
|
||||
//! platform's real accessibility tree, so this crate only has to keep that
|
||||
//! tree in sync with `ui::access::AccessTree`'s output -- nothing here
|
||||
//! reacts to an AccessKit action request, which is why the three handlers
|
||||
//! below are inert. See RUST.md's I4 box for why: on Android (and, by the
|
||||
//! same platform convention, everywhere else) a screen reader's element tap
|
||||
//! is a real touch delivered at the node's own bounds, not an action
|
||||
//! request synthesised in-process -- so the ordinary pointer path already
|
||||
//! handles it once the bounds are right.
|
||||
use accesskit::{ActionHandler, ActionRequest, ActivationHandler, DeactivationHandler, TreeUpdate};
|
||||
|
||||
pub struct NullActivationHandler;
|
||||
|
||||
@@ -27,9 +27,6 @@ pub struct App<State: AppState> {
|
||||
|
||||
impl<State: AppState> App<State> {
|
||||
pub fn run() {
|
||||
// The desktop's `main` in everything but name -- see
|
||||
// `super::logging`'s doc for why the logger goes here and what
|
||||
// its absence hid.
|
||||
super::logging::install(log::LevelFilter::Info);
|
||||
let event_loop = EventLoop::with_user_event().build().unwrap();
|
||||
let proxy = event_loop.create_proxy();
|
||||
|
||||
@@ -18,8 +18,6 @@ impl<T: HasDefaultUiState> FocusHost for T {
|
||||
let state = self.default_state_mut();
|
||||
let Some(region) = region else { return };
|
||||
state.window.set_ime_allowed(true);
|
||||
// Physical, like everything else this backend hands winit --
|
||||
// `default::content_scale`.
|
||||
state.window.set_ime_cursor_area(
|
||||
PhysicalPosition::<f32>::from(region.top_left.tuple()),
|
||||
PhysicalSize::<f32>::from(region.size().tuple()),
|
||||
|
||||
@@ -1,27 +1,3 @@
|
||||
//! A stderr logger for the desktop entry point.
|
||||
//!
|
||||
//! Without one, `log::` calls on this side go nowhere: `log`'s default is
|
||||
//! a no-op logger, and nothing in `desktop-app` or the examples ever
|
||||
//! installed a real one. That is how iris came to have a renderer that
|
||||
//! silently fell back to GLES (and, on this VM, on to llvmpipe when the
|
||||
//! host took its GPU away) with **no record anywhere of what
|
||||
//! drew the frame** -- a layer-2 screenshot off llvmpipe and one off the
|
||||
//! host GPU are the same PNG, and the difference is exactly what a
|
||||
//! screenshot is being taken to judge.
|
||||
//!
|
||||
//! Installed by [`DefaultApp::run`](super::app::DefaultApp::run) rather
|
||||
//! than by a library call somewhere, because that function already takes
|
||||
//! over the process -- it owns the event loop and does not return -- so
|
||||
//! it is the desktop's `main` in everything but name, and one install
|
||||
//! there covers `desktop-app` and every example at once. `try_init`
|
||||
//! rather than `init`: a binary that installed its own logger first keeps
|
||||
//! it, and a second `DefaultApp::run` in one process is not an error.
|
||||
//!
|
||||
//! Deliberately not `env_logger`. All this owes the reader is a level and
|
||||
//! a line, which is a page of code against a dependency plus its own
|
||||
//! filter dialect; the Android side is `android_logger` for the same
|
||||
//! reason -- one line per platform's own convention.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use log::{Level, LevelFilter, Log, Metadata, Record};
|
||||
@@ -74,9 +50,6 @@ impl Log for StderrLogger {
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the stderr logger unless this process already has one.
|
||||
/// Defaults to `info`, which is where the renderer says which adapter it
|
||||
/// got; `RUST_LOG=debug` adds iris's own per-frame lines.
|
||||
pub fn install(default: LevelFilter) {
|
||||
let level = level_from_env(default);
|
||||
let logger = Box::leak(Box::new(StderrLogger { level }));
|
||||
|
||||
@@ -39,12 +39,6 @@ pub type Proxy<Event> = EventLoopProxy<Event>;
|
||||
/// display whose scale factor is not 1.0, and it rasterised glyphs at
|
||||
/// one resolution to display them at another -- the blur the phone's own
|
||||
/// stopgap produced before `dp` existed.
|
||||
///
|
||||
/// **`IRIS_SCALE` overrides it**, which is how a phone-shaped desktop
|
||||
/// window runs the phone's density (`run-headless.sh --phone`,
|
||||
/// docs/RUST.md's layer 2). An unparsable value is a typo in a command
|
||||
/// somebody just typed, so it says so and uses the window's own answer
|
||||
/// rather than silently laying out at the wrong density.
|
||||
pub fn content_scale(window: &Window) -> f32 {
|
||||
match std::env::var("IRIS_SCALE") {
|
||||
Err(_) => window.scale_factor() as f32,
|
||||
@@ -67,16 +61,7 @@ pub struct DefaultUiState {
|
||||
pub window: Arc<Window>,
|
||||
pub ime: usize,
|
||||
pub last_click: Instant,
|
||||
/// I4 (RUST.md): pushed through in `DefaultApp::window_event`'s
|
||||
/// `RedrawRequested` arm, from `access`'s output. Built in
|
||||
/// `DefaultApp::new`, which is the only place with the
|
||||
/// `&ActiveEventLoop` `accesskit_winit::Adapter::with_direct_handlers`
|
||||
/// needs -- see that constructor's doc comment on why the window must
|
||||
/// still be invisible when it is called.
|
||||
pub access_adapter: accesskit_winit::Adapter,
|
||||
/// The AccessKit tree itself -- see `iris_core::AccessTree`'s doc
|
||||
/// comment for the flat shape and why it only rebuilds on a real
|
||||
/// change.
|
||||
pub access: AccessTree,
|
||||
}
|
||||
|
||||
@@ -228,12 +213,6 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
||||
type Event = State::Event;
|
||||
|
||||
fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy<Self::Event>) -> Self {
|
||||
// `accesskit_winit::Adapter::with_direct_handlers` panics if the
|
||||
// window is already visible when it's built, so the window is
|
||||
// created hidden and only shown once the adapter exists -- the one
|
||||
// extra step I4 (RUST.md) needs here. The three handlers are inert
|
||||
// (see `access.rs`): a screen reader's tap is a real touch at the
|
||||
// node's bounds, not an action request this process has to answer.
|
||||
let window = event_loop
|
||||
.create_window(State::window_attributes().with_visible(false))
|
||||
.unwrap();
|
||||
@@ -342,12 +321,6 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
||||
let frame_start = std::time::Instant::now();
|
||||
let animating = rsc.ui_mut().tick_animations(frame_start);
|
||||
let ui_state = state.default_state_mut();
|
||||
// Asked for before the work rather than after it, the same
|
||||
// way `IrisViewPeer::render` does and for the same reason
|
||||
// -- see the longer comment there. winit coalesces
|
||||
// repeated requests, so the only thing the order changes
|
||||
// is whether the request is in before this frame's draw
|
||||
// can push it past a vsync boundary.
|
||||
if animating {
|
||||
ui_state.window.request_redraw();
|
||||
}
|
||||
@@ -356,11 +329,6 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
||||
let mut parts = ui_state.renderer.draw();
|
||||
parts.total = frame_start.elapsed();
|
||||
crate::diagnostics::log_frame(render, frame_start, parts, animating);
|
||||
// I4 (RUST.md): only produces a `TreeUpdate` when the named
|
||||
// set actually changed this frame -- see `AccessTree`'s doc
|
||||
// comment. `render` reflects the draw that just happened,
|
||||
// so `resolved_region`/`window_region` inside it report a
|
||||
// moved subtree's *new* position, not last frame's.
|
||||
if let Some(tree_update) = ui_state.access.update(rsc.widgets(), render, rsc) {
|
||||
ui_state.access_adapter.update_if_active(|| tree_update);
|
||||
}
|
||||
@@ -369,16 +337,6 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
||||
render.resize((size.width, size.height));
|
||||
ui_state.renderer.resize(size)
|
||||
}
|
||||
// Dragging the window to a display with a different scale.
|
||||
// Both copies again, the pair `new` sets at startup -- read
|
||||
// through `content_scale` rather than from the event, so
|
||||
// `IRIS_SCALE` still pins the density it was given (the
|
||||
// `--phone` window must not follow the monitor). winit sends
|
||||
// the matching `Resized` separately. Before 2026-09-07 this
|
||||
// event was unhandled, so every `dp` and every rasterised
|
||||
// glyph stayed at the density the window opened on
|
||||
// (review, 2026-09-07) -- invisible on this
|
||||
// machine, where every display is 1.0.
|
||||
WindowEvent::ScaleFactorChanged { .. } => {
|
||||
let scale = content_scale(ui_state.window.as_ref());
|
||||
rsc.ui.text.density = scale;
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
use crate::platform::OpenUrl;
|
||||
use crate::prelude::HasDefaultUiState;
|
||||
|
||||
/// The desktop's URL opener: the platform's own "open this with whatever
|
||||
/// is registered for it" command, detached so a browser starting slowly
|
||||
/// cannot stall the event loop.
|
||||
///
|
||||
/// A command rather than a crate: `xdg-open`/`open`/`start` is what every
|
||||
/// such crate shells out to anyway, and this is one call site.
|
||||
impl<T: HasDefaultUiState> OpenUrl for T {
|
||||
fn open_url(&mut self, url: &str) {
|
||||
let (program, first): (&str, &[&str]) = if cfg!(target_os = "macos") {
|
||||
("open", &[])
|
||||
} else if cfg!(target_os = "windows") {
|
||||
// `start` is a shell builtin, and its first argument is the
|
||||
// window title -- an empty one, or a URL containing `&` ends
|
||||
// up split.
|
||||
("cmd", &["/C", "start", ""])
|
||||
} else {
|
||||
("xdg-open", &[])
|
||||
@@ -25,8 +16,6 @@ impl<T: HasDefaultUiState> OpenUrl for T {
|
||||
.spawn()
|
||||
{
|
||||
Ok(_) => {}
|
||||
// Named with the command that failed and the link it was for,
|
||||
// since neither is recoverable from the OS error alone.
|
||||
Err(e) => log::warn!("could not open {url} with {program}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,12 +68,6 @@ impl UiRenderer {
|
||||
|
||||
let submit_start = Instant::now();
|
||||
self.queue.submit(std::iter::once(encoder.finish()));
|
||||
// Immediately before presenting, so the windowing system can schedule
|
||||
// the frame. On Wayland this is what ties the commit to the surface's
|
||||
// frame callback; without it a frame drawn when nothing else follows
|
||||
// could sit unpresented, and the window kept the layout it had before
|
||||
// the compositor's first resize -- intermittently, on about a fifth of
|
||||
// starts, with nothing left to flush it.
|
||||
self.window.pre_present_notify();
|
||||
self.queue.present(output);
|
||||
FrameParts::waits(acquire, submit_start.elapsed())
|
||||
@@ -83,7 +77,6 @@ impl UiRenderer {
|
||||
self.config.width = size.width;
|
||||
self.config.height = size.height;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
// Physical, matching `new`'s own seed -- see the comment there.
|
||||
self.ui.resize(
|
||||
Vec2::new(size.width as f32, size.height as f32),
|
||||
&self.queue,
|
||||
@@ -117,16 +110,6 @@ impl UiRenderer {
|
||||
backends,
|
||||
..InstanceDescriptor::new_with_display_handle(Box::new(window.clone()))
|
||||
});
|
||||
// The same fallback the Android backend grew in 85869d0, and for
|
||||
// the same reason: a machine can advertise a Vulkan ICD with no
|
||||
// device behind it, and refusing to draw at all because the only
|
||||
// usable adapter is a GLES one is iris's bug rather than the
|
||||
// machine's. On this VM the Vulkan device disappears whenever
|
||||
// the host refuses a virtio-gpu context, so `run-headless.sh` --
|
||||
// layer 2 of the test rig -- aborted with `Could not get
|
||||
// adapter!` while GL was sitting there working. Probed before the
|
||||
// surface exists, matching Android, where an instance carrying
|
||||
// both backends fails worse than one carrying the wrong one.
|
||||
if backends != Backends::GL && instance.enumerate_adapters(backends).block_on().is_empty() {
|
||||
log::warn!(
|
||||
"iris renderer: no {backends:?} adapter on this machine, falling back to GLES"
|
||||
@@ -154,14 +137,6 @@ impl UiRenderer {
|
||||
panic!("No usable GPU adapter for backends {backends:?}: {error}")
|
||||
});
|
||||
|
||||
// Say which adapter won, in the same words the Android backend
|
||||
// uses. Without it a layer-2 screenshot or frame time from this
|
||||
// window carries no record of what drew it, and the two cases that
|
||||
// matter look identical in the PNG: the host's real GPU, and
|
||||
// llvmpipe after this VM lost its virtio-gpu contexts. That
|
||||
// happened on 2026-09-08, and the only reason anyone noticed is
|
||||
// that the fallback above did not exist yet and the app aborted
|
||||
// instead. A silent fallback needs this line to stay honest.
|
||||
{
|
||||
let info = adapter.get_info();
|
||||
log::info!(
|
||||
|
||||
@@ -1,30 +1,3 @@
|
||||
//! The trace toggle for the `iris::input`/`iris::frame` diagnostics (Iris's
|
||||
//! 2026-09-07 request: "add another button to copy input event info ...
|
||||
//! instrument a lot of the code with timings"), and the one place both
|
||||
//! call sites' `iris::frame` line is written from.
|
||||
//!
|
||||
//! **Why a crate-level flag instead of `log::log_enabled!`/
|
||||
//! `log::set_max_level`**: the app already installs its logger at
|
||||
//! `LevelFilter::Debug` (`iris/android-app/src/lib.rs`'s `JNI_OnLoad`), so
|
||||
//! a `log::Level::Debug` line reaches `client_core::log_ring`'s ring
|
||||
//! regardless of what this instrument would prefer -- `RingLogger::enabled`
|
||||
//! is unconditionally `true` by design (its own doc: "the ring wants
|
||||
//! everything"). So the level alone cannot give these two targets a
|
||||
//! default-off switch; the gate has to live on this side, checked before
|
||||
//! `log::debug!` is even reached.
|
||||
//!
|
||||
//! **Why default off matters**: the ring is 2000 lines / 256 KiB
|
||||
//! (`client_core::log_ring::DEFAULT_MAX_LINES`/`DEFAULT_MAX_BYTES`), and a
|
||||
//! 120Hz session logging both a line per touch sample and a line per frame
|
||||
//! fills that in seconds -- so a caller turns this on only for the length
|
||||
//! of whatever is being investigated, and the report says so at its top
|
||||
//! (a caller's job; see `iris::diagnostics::trace_enabled` used at the top
|
||||
//! of whatever builds the report).
|
||||
//!
|
||||
//! **Not yet wired to a control**: the Diagnostics pane that would hold the
|
||||
//! switch is in `iris/android-app/src/bench_client.rs`, which another agent
|
||||
//! has open at the same time this was written. `set_trace` is the whole
|
||||
//! surface a button needs; wiring one is a follow-up.
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -32,17 +5,10 @@ use iris_core::{FrameParts, UiRenderState};
|
||||
|
||||
static TRACE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Turns the `iris::input`/`iris::frame` `debug!` lines on or off. Off by
|
||||
/// default -- see the module doc for why turning the level on alone would
|
||||
/// not do it.
|
||||
pub fn set_trace(on: bool) {
|
||||
TRACE.store(on, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether the `iris::input`/`iris::frame` lines are enabled right now --
|
||||
/// what a report's header reads before deciding what to say about the
|
||||
/// lines it does or doesn't hold (UI_RULES.md: "design the unknown state
|
||||
/// first").
|
||||
pub fn trace_enabled() -> bool {
|
||||
TRACE.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
@@ -4,16 +4,10 @@ use std::sync::Arc;
|
||||
|
||||
use crate::task::{TaskCtx, TaskUpdate, Tasks};
|
||||
|
||||
/// A field's Enter key (without a shift, in a multi-line field). Backend
|
||||
/// input handling raises it directly rather than through `on`, since a
|
||||
/// field does not know ahead of time whether anything is listening.
|
||||
#[derive(Eq, PartialEq, Hash, Clone)]
|
||||
pub struct Submit;
|
||||
impl Event for Submit {}
|
||||
|
||||
/// A field's content changed as a result of input the backend applied
|
||||
/// directly to it (a keystroke, an IME commit) rather than through a
|
||||
/// widget event handler.
|
||||
#[derive(Eq, PartialEq, Hash, Clone)]
|
||||
pub struct Edited;
|
||||
impl Event for Edited {}
|
||||
@@ -44,13 +38,6 @@ impl<WL: WidgetLike<Rsc, Tag>, Rsc: HasEvents, Tag> Eventable<Rsc, Tag> for WL {
|
||||
|
||||
widget_trait! {
|
||||
pub trait TaskEventable<Rsc: HasEvents + HasTasks>;
|
||||
/// No `Data: Send` bound, deliberately: the registered handler below
|
||||
/// takes `|_, rsc|` and the event's data never crosses into the
|
||||
/// spawned future -- `AsyncEventIdCtx` carries the widget id and the
|
||||
/// task handle and nothing else. The bound used to be here anyway, and
|
||||
/// it was the whole reason `CursorData`'s pointer state was behind a
|
||||
/// `Mutex` rather than owned by the input handler (Iris, 2026-09-08:
|
||||
/// never reach for a lock first).
|
||||
fn task_on<E: EventLike, F: AsyncWidgetEventFn<Rsc, WL::Widget>>(
|
||||
self,
|
||||
event: E,
|
||||
|
||||
@@ -1,33 +1,3 @@
|
||||
//! Layer 1 of docs/RUST.md's "Three test layers": a whole screen driven
|
||||
//! in-process with **no window, no compositor and no GPU**, on an
|
||||
//! explicit clock and a replayed touch stream.
|
||||
//!
|
||||
//! `layout_tests.rs` and `sense_tests.rs` already build trees over
|
||||
//! `UiRenderState` with a hand-rolled `Rsc` each; this is the same idea
|
||||
//! carried far enough to open a real app screen (`transcript-ui`'s, over
|
||||
//! the bench fixture -- see the `transcript-fixture` crate) at the
|
||||
//! phone's size and density, feed it a recorded flick, and assert on
|
||||
//! where the list ended up. What it answers that the emulator cannot:
|
||||
//! Android batches a 120Hz flick into one or two `MotionEvent`s
|
||||
//! (`CursorState::time`), and a `ui-trace` swipe is many evenly-spaced
|
||||
//! ones -- so the gesture shape a finger actually makes is only
|
||||
//! reproducible from a *file* of timestamped samples.
|
||||
//!
|
||||
//! It is a third backend in the sense `default/` and `android/` are, and
|
||||
//! deliberately the smallest one: the platform half of each of those
|
||||
//! (a surface, an IME, a URL opener) becomes a recorded fact here --
|
||||
//! [`HarnessState::keyboard_shown`], [`HarnessState::opened_urls`] --
|
||||
//! so a test can assert the platform *was asked*, which is the only
|
||||
//! thing either backend does with those calls anyway.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||
//! let screen = transcript_ui::build(&mut h.rsc, &mut h.state, rows);
|
||||
//! h.frame(0);
|
||||
//! h.replay(&TouchScript::parse(include_str!("flick.touch"))?);
|
||||
//! h.frames_until(20, 2_000, 8);
|
||||
//! ```
|
||||
|
||||
use crate::prelude::*;
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
@@ -61,9 +31,6 @@ impl TouchAction {
|
||||
}
|
||||
}
|
||||
|
||||
/// The inverse of [`Self::parse`] -- what [`Harness::touch`] hands
|
||||
/// [`crate::sense::log_input_event`], so an `iris::input` line and a
|
||||
/// `.touch` file agree on one spelling of each action.
|
||||
pub fn word(self) -> &'static str {
|
||||
match self {
|
||||
Self::Down => "down",
|
||||
@@ -76,27 +43,16 @@ impl TouchAction {
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct TouchSample {
|
||||
/// Milliseconds since the start of the recording -- the sample's own
|
||||
/// time, which becomes `CursorState::time`. See that field's doc for
|
||||
/// why a replay may not date its samples by when the loop got to
|
||||
/// them.
|
||||
pub t_ms: u64,
|
||||
pub action: TouchAction,
|
||||
pub pos: Vec2,
|
||||
}
|
||||
|
||||
/// A recorded gesture: one `t_ms action x y` line per sample, `#` and
|
||||
/// blank lines ignored. Deliberately a plain text file rather than a
|
||||
/// serialisation format -- it is written by hand as often as it is
|
||||
/// recorded, and a diff of one has to be readable.
|
||||
pub struct TouchScript {
|
||||
pub samples: Vec<TouchSample>,
|
||||
}
|
||||
|
||||
impl TouchScript {
|
||||
/// Parses a script, naming the line and what was wrong with it: these
|
||||
/// are hand-written files, so a typo is the ordinary case and
|
||||
/// "expected 4 fields" without a line number is not enough to fix it.
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let mut samples: Vec<TouchSample> = Vec::new();
|
||||
for (i, line) in text.lines().enumerate() {
|
||||
@@ -134,16 +90,11 @@ impl TouchScript {
|
||||
Ok(Self { samples })
|
||||
}
|
||||
|
||||
/// The last sample's time, i.e. how long the recording runs.
|
||||
pub fn end_ms(&self) -> u64 {
|
||||
self.samples.last().map(|s| s.t_ms).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts the frames something asked for without drawing any -- the
|
||||
/// harness's `RequestRedraw`. A `LazySpan` coasting through a fling asks for
|
||||
/// the next frame through this (`UiData::animate` and `Widget::tick`), so a test can
|
||||
/// tell "nothing moved" from "nothing was even asked to move".
|
||||
#[derive(Default)]
|
||||
pub struct RedrawCounter(AtomicUsize);
|
||||
|
||||
@@ -159,8 +110,6 @@ impl RequestRedraw for RedrawCounter {
|
||||
}
|
||||
}
|
||||
|
||||
/// The harness's app state: what each real backend keeps for the platform
|
||||
/// half, recorded instead of performed.
|
||||
pub struct HarnessState {
|
||||
pub root: Option<StrongWidget>,
|
||||
pub focus: Option<WeakWidget<TextEdit>>,
|
||||
@@ -171,7 +120,6 @@ pub struct HarnessState {
|
||||
/// available here, so this says what was *asked*, and a test must not
|
||||
/// read it as "the IME is up".
|
||||
pub keyboard_shown: usize,
|
||||
/// Every URL a tapped link asked the platform to open, in order.
|
||||
pub opened_urls: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -296,8 +244,6 @@ pub struct Harness {
|
||||
task_recv: TaskMsgReceiver<HarnessRsc>,
|
||||
redraws: Arc<RedrawCounter>,
|
||||
cursor: CursorState,
|
||||
/// Time zero. Every `t_ms` in this harness is an offset from here, so
|
||||
/// nothing reads the wall clock -- see [`Self::at`].
|
||||
base: Instant,
|
||||
size: Vec2,
|
||||
}
|
||||
@@ -345,15 +291,10 @@ impl Harness {
|
||||
self.size
|
||||
}
|
||||
|
||||
/// How many frames were asked for so far -- see [`RedrawCounter`].
|
||||
pub fn redraws(&self) -> usize {
|
||||
self.redraws.count()
|
||||
}
|
||||
|
||||
/// One frame at `t_ms`: drain finished tasks, advance anything
|
||||
/// animating, lay out and "draw". The same three steps
|
||||
/// `DefaultApp::window_event`'s `RedrawRequested` arm and
|
||||
/// `IrisViewPeer::render` take, minus handing primitives to a GPU.
|
||||
pub fn frame(&mut self, t_ms: u64) {
|
||||
while let Ok(update) = self.task_recv.try_recv() {
|
||||
update(&mut self.state, &mut self.rsc);
|
||||
@@ -376,9 +317,6 @@ impl Harness {
|
||||
);
|
||||
}
|
||||
|
||||
/// Frames every `step_ms` up to and including `end_ms` -- what a
|
||||
/// fling needs, since it moves only while something ticks it
|
||||
/// (`ScrollController::fling`'s doc). Returns the time of the last frame run.
|
||||
pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 {
|
||||
debug_assert!(step_ms > 0, "a frame loop with no step never ends");
|
||||
let mut t = from_ms;
|
||||
@@ -389,10 +327,6 @@ impl Harness {
|
||||
t - step_ms
|
||||
}
|
||||
|
||||
/// One pointer sample through the sensors, then the frame it belongs
|
||||
/// to -- `IrisViewPeer::on_touch_event` and `after_input`, in one
|
||||
/// call. Each sample is its own input frame, dated by the sample
|
||||
/// rather than by when this ran.
|
||||
pub fn touch(&mut self, action: TouchAction, pos: Vec2, t_ms: u64) {
|
||||
self.cursor.time = self.at(t_ms);
|
||||
self.cursor.pos = pos;
|
||||
@@ -403,18 +337,11 @@ impl Harness {
|
||||
}
|
||||
TouchAction::Move => {}
|
||||
TouchAction::Up => self.cursor.buttons.left.update(false),
|
||||
// The platform taking the gesture away, not the finger
|
||||
// lifting -- see `CursorState::cancelled`.
|
||||
TouchAction::Cancel => {
|
||||
self.cursor.buttons.left.update(false);
|
||||
self.cursor.cancelled = true;
|
||||
}
|
||||
}
|
||||
// Layer 1's half of `iris::input` (`sense::log_input_event`'s own
|
||||
// doc): no batching happens here, so `historical` is always empty
|
||||
// and `t_ms` is the script's own column, which is what makes this
|
||||
// round-trip through `report_to_touch.py` back into an identical
|
||||
// `TouchScript`.
|
||||
crate::sense::log_input_event(action.word(), pos.x, pos.y, t_ms, &[]);
|
||||
let cursor = self.cursor.clone();
|
||||
self.render
|
||||
@@ -423,10 +350,6 @@ impl Harness {
|
||||
self.cursor.end_frame();
|
||||
}
|
||||
|
||||
/// Replays a whole recorded gesture. Nothing is inserted between the
|
||||
/// samples: a file with three lines produces three input frames, so
|
||||
/// the batched shape a real flick arrives in is preserved exactly as
|
||||
/// recorded rather than smoothed into evenly-spaced motion.
|
||||
pub fn replay(&mut self, script: &TouchScript) {
|
||||
for sample in &script.samples {
|
||||
self.touch(sample.action, sample.pos, sample.t_ms);
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
//! Pass conditions for LAYOUT.md section 8, exercised as plain unit tests
|
||||
//! rather than through `run-headless.sh`: `UiRenderState` and `Widgets` do
|
||||
//! not touch a GPU or a window, so a tree can be built and driven directly.
|
||||
//! No GPU-backed rendering (`UiRenderNode`) is exercised here -- only the
|
||||
//! CPU-side layout/move machinery LAYOUT.md is about.
|
||||
|
||||
use crate::prelude::*;
|
||||
use std::{cell::Cell, cell::RefCell, rc::Rc};
|
||||
|
||||
/// The minimal `UiRsc` a test needs: just the shared `UiData`, none of the
|
||||
/// event/window/state plumbing `DefaultRsc` carries. `pub(crate)` so
|
||||
/// `access_tests.rs` (I4, RUST.md) can reuse it rather than keeping a
|
||||
/// second copy of the same harness.
|
||||
pub(crate) struct TestRsc {
|
||||
pub(crate) ui: UiData,
|
||||
}
|
||||
@@ -94,14 +84,6 @@ impl Widget for TracedParent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal reproduction for a container's child-layer cursor being retained
|
||||
/// as though it were the layer on which the container itself was entered.
|
||||
///
|
||||
/// `Stack` is drawn by `Sized` on layer 0. It advances its painter to layers
|
||||
/// 1 and 2 for its two children. The retained `ActiveData` must still say the
|
||||
/// stack itself is on layer 0; otherwise an ordinary redraw of `Sized` asks
|
||||
/// for the stack on 0 again and turns the invented 2 -> 0 change into a full
|
||||
/// redraw of the stack and both children.
|
||||
#[test]
|
||||
fn a_widget_retains_its_entry_layer_not_its_child_cursor() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -198,10 +180,6 @@ fn a_parent_that_ignores_child_size_is_not_invalidated_with_it() {
|
||||
assert_eq!(&*trace.borrow(), &["child"]);
|
||||
}
|
||||
|
||||
/// A content-sized vertical container grows vertically when one child grows,
|
||||
/// but that does not invalidate another child's retained height. Its width is
|
||||
/// the context that could change that height (for example through wrapping),
|
||||
/// and that stayed fixed.
|
||||
#[test]
|
||||
fn a_span_reuses_unchanged_sibling_sizes_when_only_its_along_extent_changes() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -223,9 +201,6 @@ fn a_span_reuses_unchanged_sibling_sizes_when_only_its_along_extent_changes() {
|
||||
dir: Dir::DOWN,
|
||||
gap: Len::ZERO,
|
||||
});
|
||||
// `Sized` settles its child from the full offered box into the content
|
||||
// height, reproducing the retained-region change a nested content-sized
|
||||
// row sees when one of its children grows.
|
||||
let root = rsc
|
||||
.ui
|
||||
.widgets
|
||||
@@ -264,9 +239,6 @@ fn a_child_coordinate_offset_moves_only_the_child_subtree() {
|
||||
offset: vec2(0.0, 15.0),
|
||||
});
|
||||
let parent_weak = parent.weak();
|
||||
// Keep the coordinate-owning widget below the root: a nested widget is
|
||||
// normally redrawn when its parent visits it, which takes a different
|
||||
// retained-state path from `UiRenderState::redraw` on the root itself.
|
||||
let outer = rsc.ui.widgets.add_strong(Sized {
|
||||
inner: parent.any(),
|
||||
x: None,
|
||||
@@ -285,8 +257,6 @@ fn a_child_coordinate_offset_moves_only_the_child_subtree() {
|
||||
assert!((child_before.top_left.y - 15.0).abs() < 0.01);
|
||||
|
||||
rsc.ui.widgets.get_mut(&parent_weak).unwrap().offset.y = 35.0;
|
||||
// Force the ordinary ancestor-redraw path rather than letting the
|
||||
// renderer visit only the dirty descendant directly.
|
||||
rsc.ui.widgets.get_mut(&outer_weak).unwrap().x = None;
|
||||
render.update(&root, &mut rsc);
|
||||
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||
@@ -332,13 +302,6 @@ fn a_hinted_rest_draws_once_and_only_moves_the_fixed_child_after_it() {
|
||||
assert!((last.top_left.y - 260.0).abs() < 0.01, "{last:?}");
|
||||
}
|
||||
|
||||
/// A `ScrollArea` over a `Span` of `n` fixed-height rects -- N primitives large
|
||||
/// enough that an O(N) regression in the move path would show up as a
|
||||
/// non-trivial counter rather than being lost in noise (LAYOUT.md section
|
||||
/// 8, condition 3, using rects rather than glyphs to avoid pulling the font
|
||||
/// stack into a plain unit test). Returns the scroll widget (weak, for
|
||||
/// mutating it later), the erased root to draw, and the rows (weak, for
|
||||
/// hit-testing one of them).
|
||||
fn scrolled_rects(
|
||||
rsc: &mut TestRsc,
|
||||
n: usize,
|
||||
@@ -348,9 +311,6 @@ fn scrolled_rects(
|
||||
for _ in 0..n {
|
||||
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||
rects.push(rect.weak());
|
||||
// Each row gets a fixed height so the span's total content is
|
||||
// genuinely taller than the viewport -- rest-sized rows would just
|
||||
// divide whatever space is offered and never need scrolling.
|
||||
let row = rsc.ui.widgets.add_strong(Sized {
|
||||
inner: rect.any(),
|
||||
x: None,
|
||||
@@ -362,14 +322,6 @@ fn scrolled_rects(
|
||||
let scroll = rsc
|
||||
.ui
|
||||
.widgets
|
||||
// Anchored at the *start*: every test below scrolls down from
|
||||
// the top and states its sign convention against that. An
|
||||
// end-anchored area now sits at its end from its first drawn
|
||||
// frame (`Scroll::draw` measures and places in the same frame),
|
||||
// so `Pin::End` here would mean scrolling down from a
|
||||
// position that is already the bottom -- a clamped no-op, which
|
||||
// reads as "the move path is broken" rather than as the test
|
||||
// starting somewhere it did not mean to.
|
||||
.add_strong(ScrollArea::new(span.any(), Axis::Y, Pin::Start));
|
||||
let weak = scroll.weak();
|
||||
(weak, scroll.any(), rects)
|
||||
@@ -385,12 +337,6 @@ fn an_unchanged_frame_draws_and_rewrites_nothing() {
|
||||
render.resize((800.0, 20000.0));
|
||||
|
||||
render.update(&root, &mut rsc);
|
||||
// Two, not one: the first offers `ScrollArea`'s content the container's
|
||||
// own length as a placeholder (nothing has been measured yet) and
|
||||
// `Scroll::draw` asks to be drawn again once it knows the real one,
|
||||
// which the second update is. Only after that is the tree settled --
|
||||
// see `scrolling_moves_in_o1_without_a_redraw`'s own note on the
|
||||
// same first draw.
|
||||
render.update(&root, &mut rsc);
|
||||
render.take_counters(); // discard the first, real draws
|
||||
|
||||
@@ -408,35 +354,15 @@ fn scrolling_moves_in_o1_without_a_redraw() {
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((800.0, 600.0));
|
||||
|
||||
// The first draw offers `ScrollArea`'s content a zero-height region
|
||||
// (nothing has been measured yet) and learns the real content length
|
||||
// from what comes back; `update()` only redraws widgets actually
|
||||
// marked dirty, so that corrected length is not reflected in the
|
||||
// content's own *active* region until something -- here a no-op
|
||||
// scroll tick -- actually asks `ScrollArea` to redraw again. Only after
|
||||
// that warm-up does the content's offered size stop changing between
|
||||
// draws, which is what makes a further, real scroll tick a same-size
|
||||
// move instead of a resize. See scroll.rs.
|
||||
render.update(&root, &mut rsc);
|
||||
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
|
||||
render.update(&root, &mut rsc);
|
||||
render.take_counters();
|
||||
|
||||
// Negative: `scroll`'s sign convention subtracts from `amt`, and
|
||||
// `amt` starts at (and is clamped to) 0 at the top of the content, so
|
||||
// a *positive* argument here would be scrolling further up (a no-op,
|
||||
// already clamped) rather than actually moving anything.
|
||||
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0);
|
||||
render.update(&root, &mut rsc);
|
||||
let (draws, _rewrites, moves, _shapes) = render.take_counters();
|
||||
|
||||
// The pass condition (LAYOUT.md section 8, condition 3) is 0 draws and
|
||||
// 1 move_offsets write, independent of how many rects are in the
|
||||
// scrolled subtree. `draws` here is exactly 1: `ScrollArea` itself is
|
||||
// marked dirty by `scroll()` and its own body is cheap arithmetic with
|
||||
// no primitives of its own, so it is the one real `Widget::draw` this
|
||||
// counts -- the 500 rects underneath move via the O(1) chain and are
|
||||
// never revisited.
|
||||
assert_eq!(draws, 1, "only Scroll itself should redraw");
|
||||
assert_eq!(moves, 1, "the scrolled subtree should move in one write");
|
||||
}
|
||||
@@ -463,25 +389,12 @@ fn hit_testing_follows_a_scrolled_widget() {
|
||||
let before_px = before.to_px((800.0, 600.0).into());
|
||||
let after_px = after.to_px((800.0, 600.0).into());
|
||||
|
||||
// Scrolling by -37 moves `amt` from 0 to 37, sliding the content's
|
||||
// top-left up by 37px -- `resolved_region` (the CPU twin of the vertex
|
||||
// shader's chain walk) must reflect that immediately, not the
|
||||
// pre-scroll position, or a tap routed through it would land on
|
||||
// whatever is now at the old coordinates instead of this widget.
|
||||
assert!(
|
||||
(after_px.top_left.y - (before_px.top_left.y - 37.0)).abs() < 0.01,
|
||||
"before={before_px:?} after={after_px:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ActiveData::mask` is the mask a widget was drawn **under**, not the one
|
||||
/// it set for itself -- `redraw` feeds it straight back in as the inherited
|
||||
/// mask, so storing the set one hands a `Masked` its own mask the second
|
||||
/// time round -- which `Painter::set_mask` asserts against, since a mask
|
||||
/// that chains to itself is a clip loop. That was an abort the first time
|
||||
/// the composer's new scroll area was redrawn on the emulator; a targeted
|
||||
/// redraw of a `Masked` is what any real screen does whenever anything
|
||||
/// inside it changes.
|
||||
#[test]
|
||||
fn redrawing_a_masked_widget_does_not_nest_its_own_mask() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -536,27 +449,10 @@ fn a_mask_stays_put_while_its_scrolled_content_moves() {
|
||||
let masked_slot_after = render.active.get(&masked_id).unwrap().move_slot;
|
||||
let mask_delta_after = rsc.ui.move_offsets[masked_slot_after.idx()].delta;
|
||||
|
||||
// `Masked` itself is never the target of a `mov`/`place` here --
|
||||
// only its scrolled child is -- so the slot its own mask references
|
||||
// (`Painter::set_mask` bakes in `self.move_slot`, i.e. this one) must
|
||||
// still read zero after the scroll. The visible counterpart of this
|
||||
// (the clipped edge follows the scroll while the viewport border does
|
||||
// not) is `iris/run-headless.sh`'s job to catch in a real frame; this
|
||||
// is the numeric half, on the same data the fragment shader's
|
||||
// `resolve_move` reads. See LAYOUT.md section 2b.
|
||||
assert_eq!(mask_delta_before, [0.0, 0.0]);
|
||||
assert_eq!(mask_delta_after, [0.0, 0.0]);
|
||||
}
|
||||
|
||||
/// Reproduces `transcript_ui::composer::build_composer`'s exact tree shape
|
||||
/// (a `Rect` background stacked behind a `Span::RIGHT`-wrapped, padded,
|
||||
/// `rest`-width `TextEdit`, itself the second child of an outer
|
||||
/// `Span::DOWN` beside a `rest(1)`-height sibling) without the event/
|
||||
/// resource plumbing `composer.rs`'s builders need, to isolate whether the
|
||||
/// bug Iris reported on 2026-09-06 ("text seems to not appear in box")
|
||||
/// is this crate's layout engine or something specific to the real
|
||||
/// composer/screen. `TextEditable::edit` only needs `UiRsc`, so a plain
|
||||
/// insert exercises the exact redraw path a keystroke does.
|
||||
fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget<TextEdit>, StrongWidget) {
|
||||
let field = wtext("")
|
||||
.editable(EditMode::MultiLine)
|
||||
@@ -574,12 +470,6 @@ fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget<TextEdit>, StrongWidget)
|
||||
(field, tree)
|
||||
}
|
||||
|
||||
/// The reproduction itself. A window this tall stands in for the keyboard
|
||||
/// closed; the second, shorter `resize` stands in for `adjustResize`
|
||||
/// shrinking the surface when the IME opens -- exactly the sequence
|
||||
/// `IrisViewPeer::surface_changed` drives on a real keyboard open. Typing
|
||||
/// happens both before and after, since Iris's report was specifically
|
||||
/// that text typed *after* the keyboard was already up did not appear.
|
||||
#[test]
|
||||
fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -590,11 +480,6 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
|
||||
|
||||
render.resize((1080.0, 2298.0));
|
||||
render.update(&root, &mut rsc);
|
||||
// Focusing a field is what places its caret on a real tap
|
||||
// (`attr.rs`'s `on_press` -> `TextEditCtx::select`), and an insert
|
||||
// with no caret is a routing bug rather than a state to simulate --
|
||||
// `insert_str`'s own `debug_assert!` says so, and caught this test
|
||||
// typing into an unfocused field when it was added.
|
||||
field
|
||||
.edit(&mut rsc)
|
||||
.select(vec2(40.0, 2250.0), vec2(1080.0, 2298.0), false, false);
|
||||
@@ -602,8 +487,6 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let before_px = render.window_region(&field, &rsc).unwrap();
|
||||
// The field is one line plus 12dp of padding on a 2298-tall window --
|
||||
// nowhere near the whole window's height, and anchored at the bottom.
|
||||
assert!(
|
||||
before_px.bot_right.y - before_px.top_left.y < 200.0,
|
||||
"before a resize: {before_px:?}"
|
||||
@@ -613,10 +496,6 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
|
||||
"expected the bar near the bottom before a resize: {before_px:?}"
|
||||
);
|
||||
|
||||
// The keyboard opens: a real `surface_changed`/`resize` to a shorter
|
||||
// window, then a further keystroke -- the redraw that must land in the
|
||||
// bar's new (also short) region, not whatever region a provisional
|
||||
// provisional placement used along the way.
|
||||
render.resize((1080.0, 1478.0));
|
||||
render.update(&root, &mut rsc);
|
||||
field.edit(&mut rsc).insert("b");
|
||||
@@ -633,13 +512,6 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `ScrollArea` used to be documented as resolving its own lengths against
|
||||
/// `Painter::output_size` -- the window -- which read as if a scroll area
|
||||
/// smaller than the screen could not work, and cost a session's
|
||||
/// investigation before the composer was wired up (docs/RUST.md,
|
||||
/// 2026-09-06). It measures `painter.px_size()` now, so this pins the
|
||||
/// three numbers that follow from the offered box: what it reports
|
||||
/// upward, what its capping parent reports, and how far it can pan.
|
||||
#[test]
|
||||
fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -654,8 +526,6 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
|
||||
let scroll = rsc
|
||||
.ui
|
||||
.widgets
|
||||
// Start-anchored, so the `scroll(-37.0)` below has somewhere to
|
||||
// go -- see `scrolled_rects`' note on the same choice.
|
||||
.add_strong(ScrollArea::new(tall.any(), Axis::Y, Pin::Start));
|
||||
let scroll_w = scroll.weak();
|
||||
let scroll_id = scroll.id();
|
||||
@@ -669,17 +539,10 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((800.0, 600.0));
|
||||
// Two passes: the first offers the content a zero-length region
|
||||
// (nothing measured yet) and learns the real content length from what
|
||||
// comes back -- see `scrolling_moves_in_o1_without_a_redraw` for why
|
||||
// that warm-up is deliberate rather than a bug.
|
||||
render.update(&root, &mut rsc);
|
||||
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(0.0);
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
// Reports the *content*, so the cap above it has something to cap;
|
||||
// reporting the container instead would make the answer a function of
|
||||
// itself, since the container is sized from this very number.
|
||||
assert_eq!(
|
||||
render.active.get(&scroll_id).unwrap().size.y,
|
||||
Len::abs(1000.0)
|
||||
@@ -690,10 +553,6 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
|
||||
"the cap, not the content and not the window"
|
||||
);
|
||||
|
||||
// Panning is bounded by content minus *container*: 900, not the 400
|
||||
// a 600px window would give. The draw is what spends the delta -- a
|
||||
// controller banks it until the layout that knows where the content
|
||||
// ends (`ScrollController::take_delta`).
|
||||
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-10_000.0);
|
||||
render.update(&root, &mut rsc);
|
||||
assert!(
|
||||
@@ -703,13 +562,6 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The half `hit_testing_follows_a_scrolled_widget` could not see: it
|
||||
/// checks a *descendant* of the widget `ScrollArea` actually moves, whose own
|
||||
/// `region` is stale and is corrected entirely by the move chain. The
|
||||
/// moved widget itself had its `region` updated *and* the chain delta
|
||||
/// added on top, so its hit box sat at twice the pan -- which is why a
|
||||
/// finger pan of the composer left its field untappable. See
|
||||
/// `ActiveData::move_applied`.
|
||||
#[test]
|
||||
fn a_panned_widgets_own_hit_box_moves_exactly_once() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -725,8 +577,6 @@ fn a_panned_widgets_own_hit_box_moves_exactly_once() {
|
||||
let scroll = rsc
|
||||
.ui
|
||||
.widgets
|
||||
// Start-anchored, so the `scroll(-37.0)` below has somewhere to
|
||||
// go -- see `scrolled_rects`' note on the same choice.
|
||||
.add_strong(ScrollArea::new(tall.any(), Axis::Y, Pin::Start));
|
||||
let scroll_w = scroll.weak();
|
||||
let root = scroll.any();
|
||||
@@ -748,16 +598,6 @@ fn a_panned_widgets_own_hit_box_moves_exactly_once() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A `Masked` used to allocate a **new** mask slot on every draw, and
|
||||
/// `draw_inner`'s unchanged-region fast path means its descendants are
|
||||
/// mostly *not* redrawn with it -- so they went on referencing the slot
|
||||
/// they were first drawn under, whose region had since stopped being the
|
||||
/// widget's. Measured 2026-09-06 on the composer's tree: four live mask
|
||||
/// entries, none of them the `Masked`'s current box, and the field it was
|
||||
/// meant to clip drew nothing at all on the emulator. The slot is
|
||||
/// allocated once and rewritten in place now (`ActiveData::own_mask`), so
|
||||
/// this pins both halves: one entry, and that entry is the widget's own
|
||||
/// region.
|
||||
#[test]
|
||||
fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -769,9 +609,6 @@ fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
|
||||
inner: inner_root,
|
||||
});
|
||||
let masked_id = masked.id();
|
||||
// Placed at the bottom of a `Span::DOWN` behind a `rest(1)` sibling,
|
||||
// which is what moves the bar away from the provisional slot it is
|
||||
// first drawn at -- the move that left the stale mask behind.
|
||||
let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK));
|
||||
let filler = rsc.ui.widgets.add_strong(Sized {
|
||||
inner: filler.any(),
|
||||
@@ -808,14 +645,6 @@ fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A `dp` cap that has done its job must be reported in pixels. `Span`
|
||||
/// places a child using the `abs`/`rel` of the length it reported, so a
|
||||
/// `MaxSize` handing back the caller's own `dp(168)` gave the composer's
|
||||
/// bar a slot of **zero** the moment its content grew past six lines --
|
||||
/// and the `ScrollArea` inside then measured its container at -63px (the
|
||||
/// padding, subtracted from nothing) and panned the whole message out of
|
||||
/// view. Measured on this checkout's emulator, 2026-09-06:
|
||||
/// `container=-63 content=415.8 amt=478.8`. See `Len::fold_dp`.
|
||||
#[test]
|
||||
fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -858,15 +687,6 @@ fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The sibling of `a_panned_widgets_own_hit_box_moves_exactly_once`, on
|
||||
/// the branch that fix had no reason to touch: `draw_inner`'s
|
||||
/// size-independent fast path rewrites a widget's primitives *in place*
|
||||
/// and leaves its move slot alone, so unlike `mov` there is no slot delta
|
||||
/// for `region` to have absorbed. Counting one there anyway makes
|
||||
/// `resolved_region` subtract a delta the chain never held, and the
|
||||
/// widget's hit box lands short of where it is drawn by exactly the
|
||||
/// distance it just moved -- with nothing on screen to say so, since the
|
||||
/// primitives are in the right place.
|
||||
#[test]
|
||||
fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -879,9 +699,6 @@ fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at(
|
||||
y: Some(Len::abs(100.0)),
|
||||
});
|
||||
let spacer_w = spacer.weak();
|
||||
// `Rect` is `is_size_independent`, so growing the spacer above it
|
||||
// offers this one a region that changed *both* position and size --
|
||||
// the one shape that reaches the branch under test.
|
||||
let below = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||
let below_w = below.weak();
|
||||
let mut span = Span::empty(Dir::DOWN);
|
||||
@@ -909,17 +726,9 @@ fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at(
|
||||
);
|
||||
}
|
||||
|
||||
/// A parent that both `mov`s a child (its own layout moved the box it
|
||||
/// offers) and places it inside that box in the same frame -- what
|
||||
/// `LazySpan::place`'s Bottom-known branch does once a row's cached height
|
||||
/// stops matching what the row reports, which is reachable as soon as a
|
||||
/// transcript row's blocks wrap (docs/IRIS_TODO.md's "Found by P1a").
|
||||
struct MoveThenPlace {
|
||||
inner: StrongWidget,
|
||||
/// Where the child is *offered* a (constant-size) box, moved between
|
||||
/// frames by the test.
|
||||
offer_top: f32,
|
||||
/// Where the child is then placed within this widget's own region.
|
||||
place_top: f32,
|
||||
}
|
||||
|
||||
@@ -945,7 +754,6 @@ impl Widget for MoveThenPlace {
|
||||
}
|
||||
}
|
||||
|
||||
/// Placement must preserve a move already applied in the same frame.
|
||||
#[test]
|
||||
fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -975,10 +783,6 @@ fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement
|
||||
"the child should be drawn where it was placed, not where it was offered: {before:?}"
|
||||
);
|
||||
|
||||
// Move the offered box without changing its size (the `mov` fast path)
|
||||
// and place the child at the same spot as before. Marking the parent
|
||||
// dirty is what a real container's own content change does; the child
|
||||
// itself is untouched, which is the case `mov` exists for.
|
||||
{
|
||||
let parent = rsc.ui.widgets.get_mut(&parent_w).unwrap();
|
||||
parent.offer_top = 200.0;
|
||||
@@ -993,22 +797,8 @@ fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// LAYOUT.md's "Masks with a shape" -- its pass conditions, at layer 1.
|
||||
//
|
||||
// The shape a mask clips to is a *primitive already drawn*, never a copy
|
||||
// of one, so "the child's clipped corner" and "the container's own corner"
|
||||
// are the same arithmetic. These say so by evaluating both and demanding
|
||||
// exact equality: an approximate assertion would also pass a second copy
|
||||
// of the radius that merely happened to agree.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const RADIUS: f32 = 20.0;
|
||||
|
||||
/// A rounded container with `.masked_by` it, holding a `Rect::REST` child
|
||||
/// that fills it -- so the child's own corners are exactly the corners
|
||||
/// being clipped away. Returns the drawn state, the mask, the child, and
|
||||
/// the shape primitive the mask points at.
|
||||
fn rounded_container(rsc: &mut TestRsc) -> (UiRenderState, MaskIdx, WidgetId, u32) {
|
||||
let child = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||
let child_id = child.id();
|
||||
@@ -1046,9 +836,6 @@ fn rounded_container(rsc: &mut TestRsc) -> (UiRenderState, MaskIdx, WidgetId, u3
|
||||
(render, mask, child_id, slot)
|
||||
}
|
||||
|
||||
/// The pass condition: the child's coverage at a corner pixel *equals*
|
||||
/// the container's own coverage there. Exactly equal, because it is the
|
||||
/// same primitive evaluated once -- LAYOUT.md's point 1.
|
||||
#[test]
|
||||
fn a_masked_child_is_clipped_by_its_container_s_own_corner() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -1062,12 +849,6 @@ fn a_masked_child_is_clipped_by_its_container_s_own_corner() {
|
||||
.expect("a mask's shape is a rect")
|
||||
.radius;
|
||||
|
||||
// Across the whole corner arc, not one point on it: a single sample
|
||||
// is satisfied by a mask that clips to the box and happens to agree
|
||||
// where the two coincide. Swept from the arc's own centre -- the
|
||||
// straight chord between the two ends of the arc lies *inside* the
|
||||
// circle everywhere, so a walk along it never leaves the shape and
|
||||
// the `outside` count below is what caught that.
|
||||
let arc_center = corners.top_left + Vec2::new(radius, radius);
|
||||
let (mut outside, mut inside) = (0, 0);
|
||||
for i in 0..=20 {
|
||||
@@ -1095,9 +876,6 @@ fn a_masked_child_is_clipped_by_its_container_s_own_corner() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A hit test asks the same question the pixels do: the corner the
|
||||
/// container rounded away is not there to be pressed, and a point just
|
||||
/// inside the curve is. LAYOUT.md's point 4.
|
||||
#[test]
|
||||
fn a_mask_s_shape_decides_what_can_be_pressed() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -1106,20 +884,16 @@ fn a_mask_s_shape_decides_what_can_be_pressed() {
|
||||
let (render, mask, _child, slot) = rounded_container(&mut rsc);
|
||||
let corners = render.primitive_corners(slot, &rsc);
|
||||
|
||||
// The very corner of the box, which the radius cut off.
|
||||
let cut = corners.top_left + Vec2::new(1.0, 1.0);
|
||||
assert!(
|
||||
!render.mask_admits(mask, cut, &rsc),
|
||||
"the corner the container rounded away is still pressable",
|
||||
);
|
||||
// The same distance in along the diagonal, past the curve.
|
||||
let inside = corners.top_left + Vec2::new(RADIUS, RADIUS);
|
||||
assert!(
|
||||
render.mask_admits(mask, inside, &rsc),
|
||||
"a point well inside the curve is not pressable",
|
||||
);
|
||||
// And the middle of an edge, which no radius touches -- the half the
|
||||
// rounding had no reason to change.
|
||||
let edge = Vec2::new(
|
||||
(corners.top_left.x + corners.bot_right.x) / 2.0,
|
||||
corners.top_left.y + 1.0,
|
||||
@@ -1130,11 +904,6 @@ fn a_mask_s_shape_decides_what_can_be_pressed() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Nested masks multiply, so a pixel inside two feathered corners is
|
||||
/// dimmed by both -- LAYOUT.md's point 2, and the "alpha should be
|
||||
/// decreased / multiplied" Iris asked for. Written as a product of the
|
||||
/// two the shader would compute separately, which is what "multiply"
|
||||
/// means and what an intersection test would get wrong.
|
||||
#[test]
|
||||
fn nested_masks_multiply_their_coverage() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -1182,8 +951,6 @@ fn nested_masks_multiply_their_coverage() {
|
||||
rounded_rect_coverage(pos, c.top_left, c.bot_right, radius)
|
||||
};
|
||||
|
||||
// A point on the corner arc, where both feathers are partial -- the
|
||||
// only place a product and a minimum differ.
|
||||
let slot = render.first_primitive(inner_shape_id).unwrap();
|
||||
let corners = render.primitive_corners(slot, &rsc);
|
||||
let pos = corners.top_left + Vec2::new(RADIUS * 0.3, RADIUS * 0.3);
|
||||
@@ -1200,11 +967,6 @@ fn nested_masks_multiply_their_coverage() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A plain `.masked()` -- no shape given -- still clips to the widget's
|
||||
/// own box with square corners, which is what every list and scroll area
|
||||
/// relies on. The half the shape work had no reason to touch, and the one
|
||||
/// that would silently round every existing clip if `set_mask` ever wrote
|
||||
/// a radius of its own.
|
||||
#[test]
|
||||
fn a_plain_mask_still_clips_to_a_square_box() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -1236,16 +998,6 @@ fn a_plain_mask_still_clips_to_a_square_box() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A scroll area created to be *read* opens at the beginning of its
|
||||
/// content, however many frames it takes to learn how long that content
|
||||
/// is.
|
||||
///
|
||||
/// The bug this pins: `content_len` was `0.0` both for "nothing here" and
|
||||
/// for "not drawn yet", so the first frame's clamp found a range of zero,
|
||||
/// read `amt == len` as "sitting at the end", and set `snap_end` -- and
|
||||
/// the frame after, now knowing the real length, jumped to it. On screen
|
||||
/// that was a code fence opening at the end of its longest line, in the
|
||||
/// middle of a word (`iris/run-headless.sh phone`, 2026-09-08).
|
||||
#[test]
|
||||
fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() {
|
||||
for (name, pin, want) in [("read", Pin::Start, 0.0), ("written", Pin::End, 4900.0)] {
|
||||
@@ -1267,10 +1019,6 @@ fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() {
|
||||
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((800.0, 100.0));
|
||||
// Twice: the first draw is the one that measures the content, and
|
||||
// the defect only showed on the second. The touch in between is
|
||||
// what asks for that second draw -- an unchanged frame draws
|
||||
// nothing at all, which is the point of the frame before it.
|
||||
render.update(&root, &mut rsc);
|
||||
let _ = rsc.ui.widgets.get_mut(&weak);
|
||||
render.update(&root, &mut rsc);
|
||||
@@ -1283,17 +1031,6 @@ fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() {
|
||||
}
|
||||
}
|
||||
|
||||
/// docs/IRIS_TODO.md's "A `Span` of `Pad`ded children inside another
|
||||
/// `Span` places those children a slot out of step", worked around in
|
||||
/// `transcript-ui/src/tool.rs` by flattening the two spans into one --
|
||||
/// which costs a tool group the inset its cards should sit inside.
|
||||
///
|
||||
/// The shape is the smallest one that reproduced it there: an outer
|
||||
/// `Span(DOWN)` whose second child is another `Span(DOWN)` whose children
|
||||
/// are each a `Pad` around a fixed-height rect. Each rect is asserted to
|
||||
/// be *drawn* where its own box is -- `primitive_corners` rather than
|
||||
/// `window_region`, since the report is about what is on screen and the
|
||||
/// two resolve the move chain differently.
|
||||
#[test]
|
||||
fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
|
||||
const PAD: f32 = 4.0;
|
||||
@@ -1376,11 +1113,6 @@ fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Growing an already-drawn row first measures its new child against the
|
||||
/// row's old height. That provisional box can end before it starts when the
|
||||
/// old trailing edge is above the new child's cursor. The size must bubble to
|
||||
/// `LazySpan` and the corrected allocation must travel back down before this
|
||||
/// update is presented; a later stream event is not a layout pass.
|
||||
#[test]
|
||||
fn a_new_child_in_a_growing_lazy_row_uses_its_final_box_immediately() {
|
||||
const FIRST: f32 = 30.0;
|
||||
|
||||
@@ -7,13 +7,6 @@
|
||||
#![feature(option_into_flat_iter)]
|
||||
#![feature(async_fn_traits)]
|
||||
|
||||
// Two windowing backends live side by side, chosen by target rather than by
|
||||
// feature flag: winit everywhere but Android, android-view on it. They are
|
||||
// mutually exclusive rather than both-compiled-in because winit's own
|
||||
// Android support pulls in `android-activity`, which needs one of its
|
||||
// `game-activity`/`native-activity` features selected -- exactly what
|
||||
// `iris-core` was kept free of, and android-view is the framework's own
|
||||
// answer to the same surface on that platform. See RUST.md's I2.
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod android;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
|
||||
@@ -1,22 +1,3 @@
|
||||
//! Capabilities a widget tree needs from whatever is hosting it, that
|
||||
//! neither iris nor the app can perform itself.
|
||||
//!
|
||||
//! Same shape as [`crate::attr::FocusHost`], and for the same reason: the
|
||||
//! interface is declared here, below, and implemented by each backend
|
||||
//! above (`default/platform.rs`, `android/platform.rs`), so a widget can
|
||||
//! ask for the capability by trait bound instead of a caller threading a
|
||||
//! callback down through every builder.
|
||||
|
||||
/// Hand a URL to whatever the platform opens URLs with.
|
||||
///
|
||||
/// One method rather than a general "run an intent"/"exec" surface: the
|
||||
/// only thing a transcript needs is to follow a link a reader tapped, and
|
||||
/// a narrower capability is a narrower thing to get wrong.
|
||||
///
|
||||
/// **Nothing is reported back.** There is no answer worth branching on --
|
||||
/// the platform either shows a browser or does not, and both are outside
|
||||
/// this process -- so failures are logged where they happen (each impl)
|
||||
/// rather than turned into a `Result` every call site would discard.
|
||||
pub trait OpenUrl {
|
||||
fn open_url(&mut self, url: &str);
|
||||
}
|
||||
+41
-1002
File diff suppressed because it is too large.
Load diff
@@ -1,11 +1,3 @@
|
||||
//! IRIS_TODO.md's "Input does not fall through by input type": a widget
|
||||
//! that only registered `click()` used to also block a `ScrollArea` meant for
|
||||
//! whatever is behind it, because `run_sensors` decided "consumed, stop
|
||||
//! looking at lower layers" from mere hover, not from anything actually
|
||||
//! matching. Exercised as a plain unit test for the same reason
|
||||
//! `layout_tests.rs` is one: `UiRenderState` and a minimal `HasEvents`
|
||||
//! impl need no GPU or window.
|
||||
|
||||
use crate::prelude::*;
|
||||
use std::{cell::Cell, rc::Rc, time::Instant};
|
||||
|
||||
@@ -62,7 +54,6 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
|
||||
events: EventManager::default(),
|
||||
};
|
||||
|
||||
// Both cover the whole window -- the button "sitting over" the list,
|
||||
// the case in IRIS_TODO.md's report.
|
||||
let list = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||
let list_weak = list.weak();
|
||||
@@ -88,9 +79,6 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
|
||||
});
|
||||
}
|
||||
|
||||
// A Stack draws its children on separate layers in order, which is
|
||||
// exactly the "one thing drawn over another" shape `run_sensors`
|
||||
// walks top layer first.
|
||||
let root = rsc
|
||||
.ui
|
||||
.widgets
|
||||
@@ -130,15 +118,6 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The bug behind "finger flings do nothing" (RUST.md's P0 phone report,
|
||||
/// defect 2): a fast gesture's `PressEnd` can land at a screen position
|
||||
/// nothing is registered at -- past the edge of whatever widget noticed
|
||||
/// the press, in a gap, or off the loaded content entirely. Before pointer
|
||||
/// capture, `run_sensors`' hit test simply delivered nothing that frame,
|
||||
/// so a widget mid-drag never saw its release and never got a chance to
|
||||
/// start a fling. `PointerRequests::capture`/`DragGesture` fix this
|
||||
/// by giving the drag's widget every frame regardless of where the
|
||||
/// pointer is, including the terminal `Drop` in place of `PressEnd`.
|
||||
#[test]
|
||||
fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
|
||||
let mut rsc = SenseRsc {
|
||||
@@ -146,8 +125,6 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
|
||||
events: EventManager::default(),
|
||||
};
|
||||
|
||||
// A small draggable widget in the corner -- the release below lands
|
||||
// far outside it, exactly the "moved off the hit region" case.
|
||||
let draggable = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
|
||||
let draggable_weak = draggable.weak();
|
||||
|
||||
@@ -159,10 +136,6 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
|
||||
CursorSense::click_or_drag() | CursorSense::unclick() | CursorSense::Drop,
|
||||
move |ctx, rsc| match ctx.data.sense {
|
||||
CursorSense::PressStart(_) | CursorSense::Pressing(_) => {
|
||||
// Any committed drag takes capture -- a real caller
|
||||
// would gate this on a `DragArbiter`/`DragGesture`
|
||||
// decision, but this test only needs to exercise the
|
||||
// capture-and-release mechanics themselves.
|
||||
ctx.data.pointer.capture(draggable_weak.id());
|
||||
let _ = rsc;
|
||||
}
|
||||
@@ -187,8 +160,6 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
|
||||
"the press should have taken capture"
|
||||
);
|
||||
|
||||
// The release lands nowhere near the widget's own region -- the exact
|
||||
// shape of a fast fling's `ACTION_UP`.
|
||||
let mut release = cursor_at((95.0, 95.0).into());
|
||||
release.buttons.left = ActivationState::End;
|
||||
render.run_sensors(&mut rsc, &mut state, release, (100.0, 100.0).into());
|
||||
@@ -206,12 +177,6 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A widget that never registers `CursorSense::Drop` at all must not be
|
||||
/// affected by someone else's capture -- capture is per-gesture, not
|
||||
/// global suppression of the whole input system for widgets that were
|
||||
/// never party to it. (Practically this matters because a captured
|
||||
/// widget's registration list still has to include `Drop` for `should_run`
|
||||
/// to ever match it; this pins that half of the contract.)
|
||||
#[test]
|
||||
fn capturing_one_widget_starves_every_other_widget_of_events() {
|
||||
let mut rsc = SenseRsc {
|
||||
@@ -257,13 +222,6 @@ fn capturing_one_widget_starves_every_other_widget_of_events() {
|
||||
);
|
||||
}
|
||||
|
||||
/// IRIS_TODO.md's "the composer has no touch-drag scroll": `ScrollArea` only
|
||||
/// answered a wheel, so a finger drag over overflowed text did nothing.
|
||||
/// End-to-end over the real wiring -- `scrollable()`'s own registration,
|
||||
/// `run_sensors`' dispatch, `ScrollController::drag`, `DragGesture`'s arbitration and
|
||||
/// pointer capture -- rather than only `ScrollController::drag`'s own unit tests in
|
||||
/// `scroll.rs`, because the registration is exactly the half those cannot
|
||||
/// see.
|
||||
#[test]
|
||||
fn a_finger_drag_over_a_scroll_area_pans_it() {
|
||||
let mut rsc = SenseRsc {
|
||||
@@ -271,7 +229,6 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
|
||||
events: EventManager::default(),
|
||||
};
|
||||
|
||||
// 1000px of content in a 100px window: room to pan.
|
||||
let scroll_strong = rect(UiColor::WHITE)
|
||||
.height(Len::abs(1000.0))
|
||||
.scrollable(Axis::Y, Pin::Start)
|
||||
@@ -282,11 +239,6 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 100.0));
|
||||
render.update(&root, &mut rsc);
|
||||
// `ScrollArea` reads its content length back from the draw it just did, so
|
||||
// the frame after is the first one that knows there is anything to pan
|
||||
// -- the one-frame lag LAYOUT.md section 4 documents. `scroll(0.0)` is
|
||||
// how `layout_tests.rs` asks for that second frame, and it also drops
|
||||
// `snap_end`, leaving this parked at the start of the content.
|
||||
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
|
||||
render.update(&root, &mut rsc);
|
||||
assert_eq!(rsc.ui.widgets.get(&scroll).unwrap().amt(), 0.0);
|
||||
@@ -302,7 +254,6 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
|
||||
"the touch-down alone must not move anything"
|
||||
);
|
||||
|
||||
// Inside the slop: still a tap as far as anything can tell.
|
||||
let mut nudge = cursor_at((50.0, 80.0 - (DRAG_SLOP - 1.0)).into());
|
||||
nudge.buttons.left = ActivationState::On;
|
||||
render.run_sensors(&mut rsc, &mut state, nudge, (100.0, 100.0).into());
|
||||
@@ -313,8 +264,6 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
|
||||
"a press inside DRAG_SLOP must not scroll"
|
||||
);
|
||||
|
||||
// Past it, upward: the content follows the finger up, which for this
|
||||
// widget means more `amt`.
|
||||
let mut drag = cursor_at((50.0, 80.0 - (DRAG_SLOP + 40.0)).into());
|
||||
drag.buttons.left = ActivationState::On;
|
||||
render.run_sensors(&mut rsc, &mut state, drag, (100.0, 100.0).into());
|
||||
@@ -325,23 +274,13 @@ fn a_finger_drag_over_a_scroll_area_pans_it() {
|
||||
"expected the 40px past the slop to pan it, got {after}"
|
||||
);
|
||||
|
||||
// And the gesture holds the pointer, so the rest of it reaches this
|
||||
// widget even once the finger leaves its box.
|
||||
assert_eq!(pointer_input(&mut rsc).holder(), Some(scroll.id()));
|
||||
}
|
||||
|
||||
/// A defect found in review, 2026-09-07. The first `MotionEvent` a view
|
||||
/// sees can be a `Move` -- the `Down` went to another view, or the view was attached
|
||||
/// mid-gesture -- and its batched samples are older than its own
|
||||
/// timestamp. Anchoring on that timestamp clamped every one of them onto
|
||||
/// the anchor, so the tracker saw three samples at one instant, the Lsq2
|
||||
/// fit went degenerate, and the flick read 0 px/s.
|
||||
#[test]
|
||||
fn the_first_events_batched_samples_are_dated_apart() {
|
||||
const MS: i64 = 1_000_000;
|
||||
let now = Instant::now();
|
||||
// A 120Hz batch: three historical samples at 0/4/8ms and the event's
|
||||
// own at 12ms.
|
||||
let clock = DeviceClock::anchored(now, 12 * MS, 0);
|
||||
|
||||
assert_eq!(
|
||||
@@ -361,9 +300,6 @@ fn the_first_events_batched_samples_are_dated_apart() {
|
||||
assert_eq!(clock.ms_since_anchor(8 * MS), 8);
|
||||
}
|
||||
|
||||
/// The same clock has to keep ordering *across* events: the sample it
|
||||
/// compares a new event's first sample against is the previous event's
|
||||
/// last one, never the anchor.
|
||||
#[test]
|
||||
fn the_clock_orders_samples_across_events() {
|
||||
const MS: i64 = 1_000_000;
|
||||
@@ -377,18 +313,6 @@ fn the_clock_orders_samples_across_events() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Iris's 2026-09-08 phone report, first half: "it keeps snapping back to
|
||||
/// some position when horizontally scrolling."
|
||||
///
|
||||
/// A `ScrollArea` that has committed to a pan holds the pointer, so the
|
||||
/// gesture's end arrives as `CursorSense::Drop` -- and `scrollable`
|
||||
/// used to register `click_or_drag | unclick` only, which `should_run`
|
||||
/// never matches a `Drop` against. So the widget never learned its own
|
||||
/// gesture had ended: its `DragArbiter` stayed `Panning` at the position
|
||||
/// the finger left, and the *next* drag's first frame was measured from
|
||||
/// there and applied in one step. The registration is
|
||||
/// `CursorSense::drag_senses()` now, which is the rule for every widget
|
||||
/// driving a `DragGesture` rather than a fact about this one.
|
||||
#[test]
|
||||
fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() {
|
||||
let mut rsc = SenseRsc {
|
||||
@@ -417,8 +341,6 @@ fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() {
|
||||
render.update(&root, rsc);
|
||||
};
|
||||
|
||||
// One pan of 40px past the slop, then a release well outside the
|
||||
// widget -- the ordinary shape of a flick.
|
||||
send(&mut render, &mut rsc, 80.0, ActivationState::Start);
|
||||
send(
|
||||
&mut render,
|
||||
@@ -435,9 +357,6 @@ fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() {
|
||||
"the release must give the pointer back"
|
||||
);
|
||||
|
||||
// A second gesture, starting where the first one did. If the arbiter
|
||||
// were still panning from the release position, this first frame
|
||||
// would apply the whole distance between the two at once.
|
||||
send(&mut render, &mut rsc, 80.0, ActivationState::Start);
|
||||
let after_second = rsc.ui.widgets.get(&scroll).unwrap().amt();
|
||||
assert!(
|
||||
@@ -448,16 +367,6 @@ fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The second half of the same report: "tapping sometimes seems to make
|
||||
/// the scrolling jump, particularly when tapping on things that have
|
||||
/// events like horizontal scrolling."
|
||||
///
|
||||
/// Two widgets see the same press -- a scroll area and, under it,
|
||||
/// something tracking the gesture for a list. When the scroll area
|
||||
/// captures, the other one is cut off completely: no `PressEnd`, no
|
||||
/// `Drop`. It has to be told, or its gesture stays open at an origin
|
||||
/// belonging to a finger that has long gone, and the next unrelated touch
|
||||
/// is measured from it.
|
||||
#[test]
|
||||
fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
|
||||
let mut rsc = SenseRsc {
|
||||
@@ -465,10 +374,6 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
|
||||
events: EventManager::default(),
|
||||
};
|
||||
|
||||
// The bystander contains the capturer on a lower visual layer: the
|
||||
// shape of a vertical transcript scroller with a higher horizontal
|
||||
// scroller inside one row. Both observe the undecided press, then only
|
||||
// the recognizer matching its direction may consume it.
|
||||
let capturer = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||
let capturer_weak = capturer.weak();
|
||||
let bystander = rsc.ui.widgets.add_strong(Stack {
|
||||
@@ -535,8 +440,6 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
|
||||
"the widget that lost the gesture must be told exactly once"
|
||||
);
|
||||
|
||||
// And exactly once: the frames after the capture reach the capturer
|
||||
// alone, so there is nothing left to cancel.
|
||||
let mut more = cursor_at((50.0, 10.0).into());
|
||||
more.buttons.left = ActivationState::On;
|
||||
render.run_sensors(&mut rsc, &mut state, more, win);
|
||||
@@ -554,16 +457,6 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Iris's rule for nested scrolling, 2026-09-08: "it should only trigger
|
||||
/// horizontal if you drag left or right, and vertical should fall through
|
||||
/// if you drag up or down."
|
||||
///
|
||||
/// One mechanism does both, and it is `DragArbiter`'s existing axis test:
|
||||
/// each scroll area's gesture commits only on its own axis, so a drag
|
||||
/// along the other one is never claimed and the enclosing area's gesture
|
||||
/// -- which sees the same press, being an ancestor rather than a sibling
|
||||
/// layer -- is the one that commits and captures. This pins the pair,
|
||||
/// including the direction the change had no reason to touch.
|
||||
#[test]
|
||||
fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() {
|
||||
for (name, to, pans, still) in [
|
||||
@@ -579,23 +472,16 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
};
|
||||
// 1000px square of content in a 100px window: room to pan either
|
||||
// way, in an X area inside a Y one.
|
||||
let seen = Rc::new(Cell::new(None));
|
||||
let record = seen.clone();
|
||||
let outer_strong = rect(UiColor::WHITE)
|
||||
.width(Len::abs(1000.0))
|
||||
.height(Len::abs(1000.0))
|
||||
.scrollable(Axis::X, Pin::Start)
|
||||
// The inner area's own handle, taken as the chain is built --
|
||||
// the whole point is to exercise `scrollable`'s real
|
||||
// registration on both, so neither is assembled by hand.
|
||||
.with_id(move |_rsc, id| {
|
||||
record.set(Some(id));
|
||||
id
|
||||
})
|
||||
// The horizontal area is visually above the vertical one, as
|
||||
// it is when a raised transcript row contains sideways content.
|
||||
.layer_offset(1)
|
||||
.scrollable(Axis::Y, Pin::Start)
|
||||
.add_strong(&mut rsc);
|
||||
@@ -607,8 +493,6 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() {
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 100.0));
|
||||
render.update(&root, &mut rsc);
|
||||
// The second frame, where each area knows its content length --
|
||||
// LAYOUT.md section 4's one-frame lag, and what drops `snap_end`.
|
||||
for a in areas {
|
||||
rsc.ui.widgets.get_mut(&a).unwrap().scroll(0.0);
|
||||
}
|
||||
@@ -639,24 +523,6 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Iris's 2026-09-08 report: "if I try to scroll vertically while a
|
||||
/// horizontal scroll animation is still active, it stays locked to the
|
||||
/// horizontal scroll", with her own diagnosis -- "tapping outside of
|
||||
/// something that a fling is currently active for should have no code in
|
||||
/// common with the fling that could influence it."
|
||||
///
|
||||
/// She was right that it was global state, and this is where it lived.
|
||||
/// `run_sensors` runs a widget one more frame *after* the pointer has
|
||||
/// left it, so a `HoverEnd` can fire ([`ActivationState::End`], which is
|
||||
/// not `Off`) -- and `should_run` derived a press from the button alone,
|
||||
/// so that farewell frame also carried a `PressStart`. A widget nowhere
|
||||
/// near the finger therefore opened a gesture, and a `ScrollArea` catching
|
||||
/// its own fling commits with no slop, so it captured the pointer and the
|
||||
/// whole gesture went to it.
|
||||
///
|
||||
/// Two areas side by side here rather than one, because "the press went
|
||||
/// to the wrong widget" and "the press went nowhere" are different
|
||||
/// failures and only the second area can tell them apart.
|
||||
#[test]
|
||||
fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
|
||||
let mut rsc = SenseRsc {
|
||||
@@ -664,11 +530,6 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
|
||||
events: EventManager::default(),
|
||||
};
|
||||
|
||||
// Two 1000px-tall scroll areas, stacked: the top half of the window
|
||||
// is the first, the bottom half the second. Each area's own handle is
|
||||
// taken as its chain is built (`with_id`, the same way the nested-axes
|
||||
// test above does it), since what is under test is `scrollable()`'s
|
||||
// real registration rather than a `ScrollArea` assembled by hand.
|
||||
let seen: [Rc<Cell<Option<WeakWidget<ScrollArea>>>>; 2] = Default::default();
|
||||
let half = |slot: &Rc<Cell<Option<WeakWidget<ScrollArea>>>>| {
|
||||
let record = slot.clone();
|
||||
@@ -691,19 +552,12 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((win.x, win.y));
|
||||
render.update(&root, &mut rsc);
|
||||
// The second frame is the first that knows how long the content is --
|
||||
// see `a_finger_drag_over_a_scroll_area_pans_it`.
|
||||
for w in [&top_w, &bottom_w] {
|
||||
rsc.ui.widgets.get_mut(w).unwrap().scroll(0.0);
|
||||
}
|
||||
render.update(&root, &mut rsc);
|
||||
|
||||
let mut state = ();
|
||||
// Flick the top area and let go: it is left flinging, and -- because
|
||||
// the release goes through `run_sensors`' capture branch, which
|
||||
// returns before the loop that would have updated anybody's hover --
|
||||
// its sensor is left `On` with the pointer no longer on it. Both
|
||||
// halves of the real gesture, since both are what the bug needs.
|
||||
let base = Instant::now();
|
||||
let mut t = 0;
|
||||
let sample = |render: &mut UiRenderState,
|
||||
@@ -746,9 +600,6 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
|
||||
);
|
||||
let flung_to = rsc.ui.widgets.get(&top_w).unwrap().amt();
|
||||
|
||||
// Now press and drag in the *bottom* area: the top area's hover
|
||||
// decays to `End` on this very sample, which is the frame that used
|
||||
// to carry a `PressStart` to it.
|
||||
t += 8;
|
||||
sample(
|
||||
&mut render,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,6 @@ use crate::prelude::*;
|
||||
pub struct Pad {
|
||||
pub padding: Padding,
|
||||
pub inner: StrongWidget,
|
||||
/// Cleared after the reported size fits the offered region.
|
||||
pub exact_region: bool,
|
||||
}
|
||||
|
||||
@@ -130,10 +129,6 @@ impl Padding {
|
||||
}
|
||||
}
|
||||
|
||||
/// Covers both a bare number (`.pad(8)`, via `Len`'s own `From<N: UiNum>`
|
||||
/// blanket -- an `abs`/physical-pixel `Len`) and a `Len` directly
|
||||
/// (`.pad(dp(10))`) with the one impl, since `Len: Into<Len>` is the
|
||||
/// reflexive case of the same bound.
|
||||
impl<T: Into<Len>> From<T> for Padding {
|
||||
fn from(amt: T) -> Self {
|
||||
Self::uniform(amt.into())
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
//! `ScrollArea`: a fixed child, slid about by a [`ScrollController`].
|
||||
//!
|
||||
//! **`docs/SCROLL.md` is the overview** -- the one sign convention, what
|
||||
//! `amt` means, and how this differs from a `LazySpan`, which scrolls
|
||||
//! itself. Read it first; this file is the detail.
|
||||
|
||||
use crate::prelude::*;
|
||||
use std::time::Instant;
|
||||
|
||||
/// A scrolling view that moves one fixed child as a subtree.
|
||||
pub struct ScrollArea {
|
||||
inner: StrongWidget,
|
||||
ctl: ScrollController,
|
||||
@@ -72,23 +65,15 @@ impl Widget for ScrollArea {
|
||||
}
|
||||
|
||||
impl ScrollArea {
|
||||
/// `pin` says which end this area opens at and clings to -- see
|
||||
/// [`Pin`], and `WidgetLike::scrollable`, which is how one of these is
|
||||
/// normally built.
|
||||
pub fn new(inner: StrongWidget, axis: Axis, pin: Pin) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
// A fixed child is laid out from the box's negative edge
|
||||
// onward, always, so the end of its content is the positive
|
||||
// one -- which is what makes `Pin::End` and `Pin::Pos` the
|
||||
// same pin here and different ones in a reversed `LazySpan`.
|
||||
ctl: ScrollController::new(Dir::new(axis, Sign::Pos), pin),
|
||||
container_len: 0.0,
|
||||
content_len: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A content-sized box offset by the current scroll amount.
|
||||
fn child_region(&self, content_len: f32) -> UiRegion {
|
||||
let axis = self.ctl.axis();
|
||||
let mut region = UiRegion::FULL;
|
||||
@@ -105,22 +90,10 @@ mod tests {
|
||||
use iris_core::UiData;
|
||||
use std::time::Duration;
|
||||
|
||||
/// A scroll area with 1000px of content in a 100px box, drawn once and
|
||||
/// settled somewhere in the middle so a drag has room in both
|
||||
/// directions.
|
||||
///
|
||||
/// Built and rendered for real rather than assembled field by field,
|
||||
/// because a delta is spent in `draw` now (the controller banks it, and
|
||||
/// only a layout knows where the content ends) -- so a test that never
|
||||
/// draws would watch `amt` never move and read that as a broken
|
||||
/// gesture.
|
||||
fn area() -> (Fixture, WidgetId) {
|
||||
area_on(Axis::Y)
|
||||
}
|
||||
|
||||
/// The same fixture on either axis -- a code fence pans sideways
|
||||
/// through one of these exactly as a field pans down, and the pair of
|
||||
/// them is what caught a fling that only worked vertically.
|
||||
fn area_on(axis: Axis) -> (Fixture, WidgetId) {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
@@ -149,17 +122,12 @@ mod tests {
|
||||
root,
|
||||
render,
|
||||
};
|
||||
// 400px in, which is the middle of the 900px of travel this
|
||||
// content has.
|
||||
fixture.get().scroll(-400.0);
|
||||
fixture.draw();
|
||||
assert!((fixture.amt() - 400.0).abs() < 0.01);
|
||||
(fixture, id)
|
||||
}
|
||||
|
||||
/// The area under test with everything needed to draw it -- the drag
|
||||
/// tests all do the same three things (reach the widget, draw, read
|
||||
/// `amt`) and each of the three is a line of arena plumbing.
|
||||
struct Fixture {
|
||||
rsc: TestRsc,
|
||||
area: WeakWidget<ScrollArea>,
|
||||
@@ -180,8 +148,6 @@ mod tests {
|
||||
self.rsc.ui.widgets.get(&self.area).unwrap().amt()
|
||||
}
|
||||
|
||||
/// One frame of a fling, the way `UiData::tick_animations` drives
|
||||
/// it: tick, then draw. Answers whether it is still going.
|
||||
fn fling_frame(&mut self, now: Instant) -> bool {
|
||||
let still = self.get().tick(now);
|
||||
self.draw();
|
||||
@@ -189,17 +155,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame of a touch gesture, followed by the draw that spends it.
|
||||
fn press(f: &mut Fixture, id: WidgetId, sense: CursorSense, y: f32, t: Instant) {
|
||||
drag(f, id, sense, Vec2::new(0.0, y), t);
|
||||
}
|
||||
|
||||
/// The same, for a gesture whose position is not on the Y axis.
|
||||
fn drag(f: &mut Fixture, id: WidgetId, sense: CursorSense, pos: Vec2, t: Instant) {
|
||||
let pointer = PointerRequests::default();
|
||||
let flung = f.get().drag(&pointer, id, sense, pos, t);
|
||||
// What `WidgetLike::scrollable`'s own handler does with the
|
||||
// answer, and the half a fling does not move without.
|
||||
if flung {
|
||||
let id = f.area.id();
|
||||
f.rsc.ui.animate(id);
|
||||
@@ -218,8 +180,6 @@ mod tests {
|
||||
0.0,
|
||||
t,
|
||||
);
|
||||
// Finger down by well past the slop: the content follows it down,
|
||||
// which for this widget means *less* `amt`.
|
||||
press(
|
||||
&mut f,
|
||||
id,
|
||||
@@ -232,7 +192,6 @@ mod tests {
|
||||
"expected the 30px past the slop to be applied downward, got amt={}",
|
||||
f.amt()
|
||||
);
|
||||
// ...and the next frame's motion is a plain per-frame delta.
|
||||
press(
|
||||
&mut f,
|
||||
id,
|
||||
@@ -243,9 +202,6 @@ mod tests {
|
||||
assert!((f.amt() - 350.0).abs() < 0.01, "amt={}", f.amt());
|
||||
}
|
||||
|
||||
/// The half the change had no reason to touch: a press that never
|
||||
/// leaves the slop is a tap, and must move nothing at all -- otherwise
|
||||
/// every tap on a scrollable field nudges its text.
|
||||
#[test]
|
||||
fn a_press_that_stays_inside_the_slop_does_not_scroll() {
|
||||
let (mut f, id) = area();
|
||||
@@ -280,8 +236,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A horizontal drag is not this widget's gesture: it must stay put
|
||||
/// rather than pick up the vertical noise in a sideways swipe.
|
||||
#[test]
|
||||
fn a_horizontal_drag_does_not_scroll() {
|
||||
let (mut f, id) = area();
|
||||
@@ -303,9 +257,6 @@ mod tests {
|
||||
assert!((f.amt() - 400.0).abs() < 0.01, "amt={}", f.amt());
|
||||
}
|
||||
|
||||
/// Panning stops at the ends of the content rather than running off,
|
||||
/// which is `update_amt`'s clamp -- checked through `drag` so the two
|
||||
/// cannot drift apart.
|
||||
#[test]
|
||||
fn a_pan_past_the_end_clamps_instead_of_running_off() {
|
||||
let (mut f, id) = area();
|
||||
@@ -327,10 +278,6 @@ mod tests {
|
||||
assert!((f.amt() - 0.0).abs() < 0.01, "amt={}", f.amt());
|
||||
}
|
||||
|
||||
/// Iris, 2026-09-08: "Flinging doesn't work in horizontal scroll
|
||||
/// areas. Flinging should be enabled by default in all scroll areas
|
||||
/// on android to match composes behavior." A release with real
|
||||
/// velocity coasts, decelerating, and settles on its own.
|
||||
#[test]
|
||||
fn a_released_pan_flings_and_settles() {
|
||||
for axis in [Axis::X, Axis::Y] {
|
||||
@@ -345,9 +292,6 @@ mod tests {
|
||||
at(0.0),
|
||||
t,
|
||||
);
|
||||
// Four samples 8ms apart, accelerating away from the start --
|
||||
// three is the fewest `VelocityTracker`'s quadratic fit can
|
||||
// use, so this is a gesture that genuinely has a velocity.
|
||||
for (i, d) in [-40.0, -100.0, -180.0, -280.0].into_iter().enumerate() {
|
||||
drag(
|
||||
&mut f,
|
||||
@@ -370,9 +314,6 @@ mod tests {
|
||||
"{axis:?}: a released pan with velocity must fling"
|
||||
);
|
||||
|
||||
// Frames at 8ms until it stops, with each step no longer than
|
||||
// the one before it -- a coast that does not decelerate is
|
||||
// the linear-spline bug this crate has had once already.
|
||||
let mut last_step = f32::INFINITY;
|
||||
let mut ticks = 0;
|
||||
let mut now = t + Duration::from_millis(32);
|
||||
@@ -397,15 +338,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The wall: a fling must not spend its remaining distance on content
|
||||
/// that is not there. Released hard toward the start, it settles
|
||||
/// exactly on it.
|
||||
#[test]
|
||||
fn a_fling_stops_at_the_end_of_the_content() {
|
||||
// Both walls. A positive delta is applied as `amt -= delta`, so a
|
||||
// positive velocity runs toward the start of the content and a
|
||||
// negative one toward its end; 1000px of content in a 100px box
|
||||
// leaves `amt` in 0..=900.
|
||||
for (velocity, wall) in [(50_000.0f32, 0.0f32), (-50_000.0, 900.0)] {
|
||||
let (mut f, _id) = area();
|
||||
f.get().fling(velocity);
|
||||
@@ -429,10 +363,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A finger on coasting content stops it there, from the first
|
||||
/// sample, with no `DRAG_SLOP` to wait out -- the catch
|
||||
/// `DragArbiter::press_start` describes, which a scroll area needs
|
||||
/// for the same reason a list does now that it can coast at all.
|
||||
#[test]
|
||||
fn a_press_on_a_coasting_area_catches_it() {
|
||||
let (mut f, id) = area();
|
||||
@@ -457,8 +387,6 @@ mod tests {
|
||||
"the down itself must not move the content, only stop it"
|
||||
);
|
||||
|
||||
// A move well under `DRAG_SLOP` still tracks the finger, because
|
||||
// this press caught something that was moving.
|
||||
drag(
|
||||
&mut f,
|
||||
id,
|
||||
|
||||
@@ -1,34 +1,3 @@
|
||||
//! The scrolling capability: one `ScrollController` holding everything a
|
||||
//! scroll position is made of, and a `Scrollable` trait for the widgets
|
||||
//! that own one.
|
||||
//!
|
||||
//! **`docs/SCROLL.md` is the overview** -- the one sign convention, what
|
||||
//! `amt` means, and which widgets scroll. Read it first; this file is the
|
||||
//! detail.
|
||||
//!
|
||||
//! Two widgets scroll in iris and they scroll differently: a
|
||||
//! [`ScrollArea`](super::ScrollArea) slides a fixed child about as a lump,
|
||||
//! and a [`LazySpan`](super::LazySpan) lays its own rows out from an
|
||||
//! anchor and cannot be slid at all. What they share is everything that is
|
||||
//! *not* the layout -- the gesture, the fling, the pin, the position and
|
||||
//! the account of how far it can still go -- so that lives here, in a
|
||||
//! plain struct each of them contains, rather than in a protocol between
|
||||
//! them (Iris, 2026-09-08: "what about adding a scroll controller that
|
||||
//! both scroll and lazy span contain").
|
||||
//!
|
||||
//! The contract with the owner is two calls, both in its `draw`:
|
||||
//!
|
||||
//! 1. [`ScrollController::take_delta`] -- what a wheel, a drag or a fling
|
||||
//! asked for since the last layout, already clamped to the travel the
|
||||
//! owner last reported.
|
||||
//! 2. [`ScrollController::set_travel`], plus whichever of
|
||||
//! [`ScrollController::moved_by`] or [`ScrollController::set_amt`] fits
|
||||
//! how that owner knows where it ended up -- movement for a layout with
|
||||
//! no fixed origin, an absolute position for one that has.
|
||||
//!
|
||||
//! Everything between the two is the owner's own layout, and everything
|
||||
//! outside them is the same for both.
|
||||
|
||||
use crate::prelude::*;
|
||||
use crate::sense::{DragGesture, Flinger, GestureOutcome, PointerRequests, PressState};
|
||||
use std::time::Instant;
|
||||
@@ -38,43 +7,15 @@ use std::time::Instant;
|
||||
/// this used to be, because the flag sat at the end of two constructors
|
||||
/// and `scrollable(axis, true)` says nothing at the call site about which
|
||||
/// end `true` is.
|
||||
///
|
||||
/// **Two pairs, because there are two questions and they are not the same
|
||||
/// one** (Iris, 2026-09-08: "that way you can select the pin based on the
|
||||
/// axis's sign rather than the direction, so for example you can assure
|
||||
/// it's always pinned to the bottom"):
|
||||
///
|
||||
/// - [`Pin::Start`] / [`Pin::End`] are **content-relative**: the first row
|
||||
/// or the newest one, wherever the layout happens to put it. A
|
||||
/// transcript wants `End` -- the newest message -- and does not care
|
||||
/// which edge of the screen that is.
|
||||
/// - [`Pin::Neg`] / [`Pin::Pos`] are **axis-absolute**: the top or left
|
||||
/// edge, and the bottom or right one, whichever end of the content sits
|
||||
/// there. What to reach for when the *screen* position is the
|
||||
/// requirement.
|
||||
///
|
||||
/// The two coincide for content laid out along the positive axis, which is
|
||||
/// everything except a reversed `LazySpan` (`Dir::UP`, `Dir::LEFT`) --
|
||||
/// where they are exact opposites, which is the whole reason both exist.
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Debug)]
|
||||
pub enum Pin {
|
||||
/// The start of the content: item 0, wherever it is drawn.
|
||||
Start,
|
||||
/// The end of the content: the newest item, wherever it is drawn.
|
||||
End,
|
||||
/// The top or left edge of the box, whichever end of the content is
|
||||
/// there.
|
||||
Neg,
|
||||
/// The bottom or right edge of the box, whichever end of the content
|
||||
/// is there.
|
||||
Pos,
|
||||
}
|
||||
|
||||
impl Pin {
|
||||
/// Resolve to the one question a scrollable actually acts on: does
|
||||
/// content appended to the end bring the view with it? `dir` is the
|
||||
/// way this owner's content runs, which is the only thing that tells
|
||||
/// the axis-absolute pair from the content-relative one.
|
||||
fn pinned_to_end(self, dir: Dir) -> bool {
|
||||
match self {
|
||||
Pin::Start => false,
|
||||
@@ -85,9 +26,6 @@ impl Pin {
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a scrollable can still travel from where it is, as of its last
|
||||
/// layout, in the same screen-space units a delta is in.
|
||||
///
|
||||
/// `f32::INFINITY` where the end is not in sight: a lazy layout genuinely
|
||||
/// does not know how much content lies past the rows it has walked, and
|
||||
/// saying "infinity" is the honest answer that `clamp` also happens to
|
||||
@@ -95,8 +33,6 @@ impl Pin {
|
||||
/// the owner reports what it *did* as well as what it can do.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Travel {
|
||||
/// The bound on a **positive** delta -- scrolling up or left, back
|
||||
/// toward the start of the content.
|
||||
pub back: f32,
|
||||
/// The bound on a **negative** delta -- scrolling down or right,
|
||||
/// onward toward the end of the content. Positive itself: it is a
|
||||
@@ -105,67 +41,28 @@ pub struct Travel {
|
||||
}
|
||||
|
||||
impl Travel {
|
||||
/// Nothing known yet, so nothing is bounded -- what a scrollable
|
||||
/// starts with and what it reports for an axis whose content it has
|
||||
/// not measured.
|
||||
pub const UNBOUNDED: Self = Self {
|
||||
back: f32::INFINITY,
|
||||
fwd: f32::INFINITY,
|
||||
};
|
||||
|
||||
/// The bound on a delta of this sign, as a positive distance.
|
||||
fn toward(&self, delta: f32) -> f32 {
|
||||
if delta >= 0.0 { self.back } else { self.fwd }
|
||||
}
|
||||
}
|
||||
|
||||
/// The state a scroll position is made of, owned by the widget that
|
||||
/// scrolls: where it is, what it was asked to do next, how far it can go,
|
||||
/// which end it clings to, and the gesture and fling that drive it.
|
||||
///
|
||||
/// See the module doc for the two-call contract with its owner, and
|
||||
/// [`Scrollable`] for the trait that reaches one.
|
||||
pub struct ScrollController {
|
||||
/// Which way this area's content runs: the axis it pans along, and the
|
||||
/// sign the content grows in. A plain `ScrollArea` always grows the
|
||||
/// positive way; a `LazySpan` passes its own `dir`, which is what
|
||||
/// tells [`Pin::Pos`]/[`Pin::Neg`] from [`Pin::Start`]/[`Pin::End`].
|
||||
dir: Dir,
|
||||
/// Where this area has got to, counting **forward through the
|
||||
/// content**: 0 at the start, growing as the reader moves on. The
|
||||
/// opposite sign to a delta, which counts the way the finger moves.
|
||||
///
|
||||
/// For a `ScrollArea` it is a position, clamped into the content's
|
||||
/// real length. For a `LazySpan` it is **movement, not position** --
|
||||
/// paging rows in above moves the origin and the span cannot say by
|
||||
/// how much, never having measured them -- so the direction is
|
||||
/// comparable between the two and the absolute value is not.
|
||||
amt: f32,
|
||||
/// Asked for but not yet laid out: how far a wheel, a drag or a fling
|
||||
/// has moved this area since the last draw. Taken and cleared by
|
||||
/// [`Self::take_delta`], which is the only place it is spent, because
|
||||
/// the owner's `draw` is the only place the walls are known.
|
||||
pending: f32,
|
||||
/// What the owner's last layout said was left, and what `take_delta`
|
||||
/// clamps against.
|
||||
travel: Travel,
|
||||
/// Whether this area is currently flush against the end of its
|
||||
/// content, so that content appended to it should bring the view
|
||||
/// along. Set from [`Pin`] at construction and recomputed by the owner
|
||||
/// at the end of every layout -- it is live state, not a preference: a
|
||||
/// reader who scrolls away from the end stops being pinned to it, and
|
||||
/// scrolling back re-pins.
|
||||
pinned_to_end: bool,
|
||||
/// Touch panning. Arbitration, `DRAG_SLOP` and pointer capture all
|
||||
/// live in `sense.rs`; only what a committed pan *means* is decided
|
||||
/// here. See [`Self::drag`].
|
||||
gesture: DragGesture,
|
||||
/// The momentum a release leaves behind. Every scroll area flings, on
|
||||
/// either axis and with nothing to opt into -- Compose's `scrollable`
|
||||
/// attaches `ScrollableDefaults.flingBehavior()` on every axis it is
|
||||
/// given, and Iris asked for the same (2026-09-08: "flinging should be
|
||||
/// enabled by default in all scroll areas on android to match composes
|
||||
/// behavior").
|
||||
fling: Flinger,
|
||||
/// Physical pixels per dp, copied from the painter on every draw --
|
||||
/// what a fling's deceleration is computed against. 1.0 until the
|
||||
@@ -188,32 +85,18 @@ impl ScrollController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which way this area pans.
|
||||
pub fn axis(&self) -> Axis {
|
||||
self.dir.axis
|
||||
}
|
||||
|
||||
/// Which way this area's content runs -- the axis it pans along and
|
||||
/// the sign it grows in. What resolves a [`Pin`].
|
||||
pub fn dir(&self) -> Dir {
|
||||
self.dir
|
||||
}
|
||||
|
||||
/// How far the content has been pulled past the container's leading
|
||||
/// edge -- see the field for what that means for each kind of owner.
|
||||
pub fn amt(&self) -> f32 {
|
||||
self.amt
|
||||
}
|
||||
|
||||
/// Pan by `amt`, in the finger's direction: **positive scrolls up or
|
||||
/// left**, moving the content the positive way along the axis. One
|
||||
/// convention, everywhere, and a screen direction rather than a
|
||||
/// logical one so that it means the same thing to a widget laid out
|
||||
/// backwards (Iris, 2026-09-08).
|
||||
///
|
||||
/// Banked rather than applied: where this area can actually go is a
|
||||
/// question only its owner's layout can answer, and the owner's `draw`
|
||||
/// is where that answer exists.
|
||||
pub fn scroll(&mut self, amt: f32) {
|
||||
self.pending += amt;
|
||||
}
|
||||
@@ -221,13 +104,6 @@ impl ScrollController {
|
||||
/// What has been asked for since the last layout, clamped to the
|
||||
/// travel that layout reported. Called once at the top of the owner's
|
||||
/// `draw`.
|
||||
///
|
||||
/// **Clipping it stops a fling**, because a fling that keeps spending
|
||||
/// its distance on content that is not there is what left a hard flick
|
||||
/// parked a whole screen past the first row of the bench fixture
|
||||
/// (docs/IRIS_TODO.md, 2026-09-07). This catches the wall the owner
|
||||
/// could already see; [`Self::set_travel`] catches the one it finds by
|
||||
/// walking.
|
||||
pub fn take_delta(&mut self) -> f32 {
|
||||
let asked = std::mem::take(&mut self.pending);
|
||||
let limit = self.travel.toward(asked);
|
||||
@@ -238,10 +114,6 @@ impl ScrollController {
|
||||
taken
|
||||
}
|
||||
|
||||
/// Record content this area really moved, and by how much, in a
|
||||
/// delta's own sign. For an owner that cannot state an absolute
|
||||
/// position -- a lazy layout, whose origin moves as rows are paged in
|
||||
/// above it.
|
||||
pub fn moved_by(&mut self, delta: f32) {
|
||||
self.amt -= delta;
|
||||
}
|
||||
@@ -253,10 +125,6 @@ impl ScrollController {
|
||||
self.amt = amt;
|
||||
}
|
||||
|
||||
/// Publish how far this area can still go, from the layout that just
|
||||
/// ran. Stops a fling with nothing left in the direction it is
|
||||
/// travelling -- the wall a lazy layout only finds by walking to it,
|
||||
/// reported in the same frame that found it.
|
||||
pub fn set_travel(&mut self, travel: Travel) {
|
||||
self.travel = travel;
|
||||
if let Some(v) = self.fling.velocity()
|
||||
@@ -266,8 +134,6 @@ impl ScrollController {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the last layout said was left. Read by an owner that has to
|
||||
/// reconcile its own walls with what it was allowed to take.
|
||||
pub fn travel(&self) -> Travel {
|
||||
self.travel
|
||||
}
|
||||
@@ -296,21 +162,6 @@ impl ScrollController {
|
||||
/// convention. Answers whether one actually started, which is the
|
||||
/// caller's cue to register the widget for frames (`UiData::animate`).
|
||||
/// Cancels any fling already in progress.
|
||||
///
|
||||
/// **Sets the fling; it does not drive it.** A fling moves only while
|
||||
/// something calls [`Self::tick`] once per frame, and what does that
|
||||
/// in a running app is `UiData::tick_animations`, over the ids
|
||||
/// `UiData::animate` was given. Split that way because the two halves
|
||||
/// have different owners: the velocity is this area's business and
|
||||
/// whether anything animates at all is the frame loop's. Missing the
|
||||
/// second call is what a finger fling did on Iris's phone for two
|
||||
/// builds -- the velocity was right and nothing ever advanced it,
|
||||
/// which looks exactly like a list that stops dead under the finger.
|
||||
///
|
||||
/// The density handed on is this area's own, taken from the painter,
|
||||
/// not `1.0`: it does **not** cancel out of the spline, and a
|
||||
/// hardcoded 1.0 against a 2.75-density screen made a flick that
|
||||
/// should coast for about a second run for 45.
|
||||
pub fn fling(&mut self, velocity: f32) -> bool {
|
||||
self.fling.start(velocity, self.density)
|
||||
}
|
||||
@@ -338,14 +189,6 @@ impl ScrollController {
|
||||
self.fling.velocity()
|
||||
}
|
||||
|
||||
/// Advance a fling by one frame, banking the distance it covered.
|
||||
/// Answers whether it is still going, which is what
|
||||
/// `UiData::tick_animations` reads to decide whether to keep the
|
||||
/// widget registered -- so an owner's `Widget::tick` is this one line.
|
||||
///
|
||||
/// Stopping at a wall is [`Self::take_delta`]'s and
|
||||
/// [`Self::set_travel`]'s, not this method's: both know where the
|
||||
/// content ends and this one does not.
|
||||
pub fn tick(&mut self, now: Instant) -> bool {
|
||||
let delta = self.fling.tick(now);
|
||||
self.scroll(delta);
|
||||
@@ -378,15 +221,6 @@ impl ScrollController {
|
||||
pos_window: Vec2,
|
||||
now: Instant,
|
||||
) -> bool {
|
||||
// A scroll area has no selection of its own to extend, so a drag
|
||||
// across the axis stays `Undecided` and one along it past the slop
|
||||
// pans, which is the whole contract here.
|
||||
//
|
||||
// `scrolling` is the other half: a finger put down on content that
|
||||
// is still coasting means "stop it here", and commits to a pan on
|
||||
// that very sample with no slop to wait out
|
||||
// (`DragArbiter::press_start`). The fling is cancelled in the same
|
||||
// breath, since the curve has no idea a finger came back down.
|
||||
let mut press = PressState::default();
|
||||
if self.gesture.starts_press(sense) {
|
||||
press.scrolling = self.fling.is_flinging();
|
||||
@@ -400,8 +234,6 @@ impl ScrollController {
|
||||
// arbiter of the caller's own (`Selection::drag`) hands
|
||||
// straight to `scroll`.
|
||||
GestureOutcome::Pan(dy) => self.scroll(dy),
|
||||
// Same sign as `Pan`, since `tick` applies it through the same
|
||||
// `scroll`.
|
||||
GestureOutcome::Released(Some(v)) => return self.fling(v),
|
||||
GestureOutcome::Undecided
|
||||
| GestureOutcome::Tapped
|
||||
@@ -427,14 +259,10 @@ pub trait Scrollable {
|
||||
fn controller(&self) -> &ScrollController;
|
||||
fn controller_mut(&mut self) -> &mut ScrollController;
|
||||
|
||||
/// Pan by `amt` -- positive scrolls up or left. See
|
||||
/// [`ScrollController::scroll`].
|
||||
fn scroll(&mut self, amt: f32) {
|
||||
self.controller_mut().scroll(amt);
|
||||
}
|
||||
|
||||
/// See [`ScrollController::fling`], including why starting one is not
|
||||
/// the same as driving it.
|
||||
fn fling(&mut self, velocity: f32) -> bool {
|
||||
self.controller_mut().fling(velocity)
|
||||
}
|
||||
@@ -443,7 +271,6 @@ pub trait Scrollable {
|
||||
self.controller_mut().cancel_fling();
|
||||
}
|
||||
|
||||
/// See [`ScrollController::drag`].
|
||||
fn drag(
|
||||
&mut self,
|
||||
pointer: &PointerRequests,
|
||||
@@ -456,8 +283,6 @@ pub trait Scrollable {
|
||||
.drag(pointer, id, sense, pos_window, now)
|
||||
}
|
||||
|
||||
/// See [`ScrollController::amt`] for what this counts, which differs
|
||||
/// between the two implementors in origin though not in direction.
|
||||
fn amt(&self) -> f32 {
|
||||
self.controller().amt()
|
||||
}
|
||||
@@ -482,20 +307,11 @@ pub trait Scrollable {
|
||||
self.controller_mut().set_pinned_to_end(pinned);
|
||||
}
|
||||
|
||||
/// Advance a fling by one frame -- an implementor's `Widget::tick` is
|
||||
/// this, and nothing else animates in a scroll area.
|
||||
fn tick_fling(&mut self, now: Instant) -> bool {
|
||||
self.controller_mut().tick(now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the two inputs of a scroll -- the wheel and a finger drag --
|
||||
/// on a widget that owns a [`ScrollController`], and hand back the id.
|
||||
///
|
||||
/// The one place either is wired, shared by `WidgetLike::scrollable` and
|
||||
/// `LazySpan::scrollable`: what differs between those two is only whether
|
||||
/// there is a `ScrollArea` in the way, and a drag registered twice is a
|
||||
/// gesture arbitrated twice.
|
||||
pub fn scroll_senses<Rsc, Tag, W, WL>(w: WL, axis: Axis) -> impl WidgetIdFn<Rsc, W>
|
||||
where
|
||||
Rsc: HasEvents,
|
||||
@@ -512,11 +328,6 @@ where
|
||||
let flung = ctx
|
||||
.widget(rsc)
|
||||
.drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time);
|
||||
// The half that actually makes it move -- a fling is set by the
|
||||
// widget and driven by the frame loop, and only this side can
|
||||
// reach the loop. Only when one actually started: registering a
|
||||
// widget that is not animating asks the next frame to find that
|
||||
// out.
|
||||
if flung {
|
||||
rsc.ui_mut().animate(id);
|
||||
}
|
||||
|
||||
@@ -40,10 +40,6 @@ impl<State, O, H: WidgetOption<State>> TextBuilder<State, O, H> {
|
||||
self.attrs.wrap = wrap;
|
||||
self
|
||||
}
|
||||
/// Per-range style overrides -- I5's inline rich text (bold, italic,
|
||||
/// inline-code monospace, link colour/underline) within one wrapped
|
||||
/// paragraph. See `SpanStyle`'s doc for why this exists and what it
|
||||
/// replaces.
|
||||
pub fn spans(mut self, spans: Vec<SpanStyle>) -> Self {
|
||||
self.spans = spans;
|
||||
self
|
||||
|
||||
@@ -8,9 +8,6 @@ use winit::{
|
||||
keyboard::{Key, NamedKey},
|
||||
};
|
||||
|
||||
/// Which way a cursor movement goes. Named here rather than taken from the text
|
||||
/// stack so that the key handling below does not have to change when the stack
|
||||
/// does; the mapping onto parley lives in one place, in `apply_motion`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Motion {
|
||||
Left,
|
||||
@@ -25,21 +22,10 @@ pub enum Motion {
|
||||
|
||||
pub struct TextEdit {
|
||||
view: TextView,
|
||||
/// `None` when the field is not focused -- which parley's `Selection` has no
|
||||
/// way to say, since it always denotes some position in the text. A
|
||||
/// collapsed selection is a caret; an uncollapsed one is a span.
|
||||
selection: Option<Selection>,
|
||||
#[cfg_attr(target_os = "android", allow(dead_code))]
|
||||
history: Vec<(String, Option<Selection>)>,
|
||||
double_hit: Option<usize>,
|
||||
/// Where an in-flight press over this field began, while it is still
|
||||
/// undecided whether the gesture is a tap (focus/show the IME) or a
|
||||
/// drag (attr.rs's `Selector`/`Selectable`, Iris 2026-09-06: a swipe
|
||||
/// over the composer must not summon the keyboard). `None` both before
|
||||
/// any press and once the gesture has been decided either way --
|
||||
/// `attr.rs` is the only reader/writer, kept `pub(crate)` rather than
|
||||
/// behind an accessor since it is pure bookkeeping with no invariant
|
||||
/// beyond "some press is undecided," same shape as `double_hit` above.
|
||||
pub(crate) press_origin: Option<Vec2>,
|
||||
pub mode: EditMode,
|
||||
}
|
||||
@@ -105,8 +91,6 @@ impl Widget for TextEdit {
|
||||
};
|
||||
let layout = self.view.buf.layout();
|
||||
|
||||
// parley reports selection as boxes in layout space, so bidi and
|
||||
// wrapped lines come out right without this code knowing about either.
|
||||
for (rect, _) in selection.geometry(layout) {
|
||||
let size = vec2(rect.width() as f32, rect.height() as f32);
|
||||
let top_left = vec2(rect.x0 as f32, rect.y0 as f32);
|
||||
@@ -130,8 +114,6 @@ impl Widget for TextEdit {
|
||||
true
|
||||
}
|
||||
|
||||
/// I4 (RUST.md): the one override that exists so far -- everything
|
||||
/// else falls back to `Widget::access_role`'s default `Unknown`.
|
||||
fn access_role(&self) -> accesskit::Role {
|
||||
match self.mode {
|
||||
EditMode::SingleLine => accesskit::Role::TextInput,
|
||||
@@ -148,11 +130,6 @@ pub struct TextEditCtx<'a> {
|
||||
}
|
||||
|
||||
impl<'a> TextEditCtx<'a> {
|
||||
/// The layout, brought up to date with the text first.
|
||||
///
|
||||
/// Every cursor movement and hit test goes through parley's layout, so an
|
||||
/// edit that left it stale would move the caret against the previous text.
|
||||
/// Shaping is skipped when nothing changed, so calling this freely is fine.
|
||||
fn layout(&mut self) -> &Layout<UiColor> {
|
||||
let attrs = self.text.view.attrs.clone();
|
||||
let width = self.text.view.wrap_width();
|
||||
@@ -161,7 +138,6 @@ impl<'a> TextEditCtx<'a> {
|
||||
self.text.view.buf.layout()
|
||||
}
|
||||
|
||||
/// Keep the selection valid after the text underneath it changed.
|
||||
#[cfg_attr(target_os = "android", allow(dead_code))]
|
||||
fn refresh(&mut self) {
|
||||
if let Some(sel) = self.text.selection {
|
||||
@@ -183,13 +159,6 @@ impl<'a> TextEditCtx<'a> {
|
||||
self.text.selection = None;
|
||||
}
|
||||
|
||||
/// [`set`](Self::set) plus a fresh set of [`SpanStyle`]s in one call --
|
||||
/// what a streamed transcript row needs, since its markdown re-renders
|
||||
/// to a new string *and* a new span list on every delta and the two
|
||||
/// have to land together (a stale span list drawn against new text can
|
||||
/// point past its end). Used by `transcript-ui`'s incremental apply
|
||||
/// (RUST.md's "streaming still costs a full rebuild" fix) rather than
|
||||
/// tearing the row's widget down and rebuilding it from scratch.
|
||||
pub fn set_with_spans(&mut self, text: &str, spans: Vec<SpanStyle>) {
|
||||
let text = self.string(text);
|
||||
self.text.view.buf.set_text(text);
|
||||
@@ -202,9 +171,6 @@ impl<'a> TextEditCtx<'a> {
|
||||
return;
|
||||
};
|
||||
let layout = self.layout();
|
||||
// Collapsing a span with an unshifted left/right puts the caret at the
|
||||
// near end rather than moving one character from the focus, which is
|
||||
// what every other editor does.
|
||||
let sel = if !select && !sel.is_collapsed() {
|
||||
match motion {
|
||||
Motion::Left | Motion::LeftWord => {
|
||||
@@ -221,8 +187,6 @@ impl<'a> TextEditCtx<'a> {
|
||||
self.text.selection = Some(sel);
|
||||
}
|
||||
|
||||
/// Replace the `len` characters before the caret. This is the IME's
|
||||
/// preedit path: it re-sends the whole composition each time.
|
||||
pub fn replace(&mut self, len: usize, text: &str) {
|
||||
let text = self.string(text);
|
||||
for _ in 0..len {
|
||||
@@ -273,7 +237,6 @@ impl<'a> TextEditCtx<'a> {
|
||||
self.set_caret(at + text.len());
|
||||
}
|
||||
|
||||
/// True when there was a span to remove.
|
||||
pub fn clear_span(&mut self) -> bool {
|
||||
let Some(sel) = self.text.selection else {
|
||||
return false;
|
||||
@@ -408,27 +371,6 @@ impl<'a> TextEditCtx<'a> {
|
||||
let prev_sel = self.text.selection;
|
||||
let prev_hit = self.text.double_hit;
|
||||
|
||||
// The layout borrows `self`, so the whole decision is made in here and
|
||||
// only the answer escapes.
|
||||
//
|
||||
// **A press that reaches here has already been hit-tested to this
|
||||
// widget, so there is no "outside" to clear the selection for.**
|
||||
// This used to compare `pos` against the *laid-out text's* box and
|
||||
// set `selection = None` for anything beyond it -- but the laid-out
|
||||
// text is smaller than the field (padding, and for an empty field a
|
||||
// box of literally zero width), so tapping an **empty** composer
|
||||
// granted focus, opened the keyboard, and left `selection` at
|
||||
// `None` -- and `insert_str` returns early on `None`, so every
|
||||
// keystroke after that was silently dropped and nothing ever
|
||||
// appeared. That is RUST.md's P0 box item 2, "composed text never
|
||||
// becomes visible at all": the buffer was empty the whole time, and
|
||||
// Gboard's suggestion strip (its own composing state, not ours) is
|
||||
// what made it look otherwise. Parley's `from_point`/
|
||||
// `extend_to_point` already clamp a point outside the layout to the
|
||||
// nearest cursor position, which is what a tap in a field's padding
|
||||
// should do anyway. Losing focus is a separate path
|
||||
// (`TextEditCtx::deselect`, called from the backend's focus
|
||||
// handling), not this one.
|
||||
let outcome = {
|
||||
let layout = self.layout();
|
||||
if drag {
|
||||
@@ -436,9 +378,6 @@ impl<'a> TextEditCtx<'a> {
|
||||
} else {
|
||||
let hit = Selection::from_point(layout, pos.x, pos.y);
|
||||
let index = hit.focus().index();
|
||||
// A second click in the same place takes the word and a third
|
||||
// the line; `double_hit` is what remembers that the previous
|
||||
// click had already grown to a word.
|
||||
Some(if recent && prev_hit == Some(index) {
|
||||
(Some(Selection::line_from_point(layout, pos.x, pos.y)), None)
|
||||
} else if recent && prev_sel.map(|s| s.focus().index()) == Some(index) {
|
||||
@@ -572,8 +511,6 @@ fn apply_motion(
|
||||
}
|
||||
}
|
||||
|
||||
/// The ends of a byte range as cursors, so collapsing a selection can put the
|
||||
/// caret at whichever end the movement asked for.
|
||||
trait RangeCursors {
|
||||
fn start_cursor(&self, layout: &Layout<UiColor>) -> parley::Cursor;
|
||||
fn end_cursor(&self, layout: &Layout<UiColor>) -> parley::Cursor;
|
||||
@@ -649,10 +586,6 @@ mod tests {
|
||||
use super::*;
|
||||
use iris_core::{TextAttrs, TextBuffer};
|
||||
|
||||
/// The editor is the one part of iris that is pure logic over a string and
|
||||
/// a layout, and it was rewritten wholesale when the text stack changed --
|
||||
/// so it is the one part worth testing directly. Everything else here
|
||||
/// needs a GPU and a window.
|
||||
fn edit(text: &str, mode: EditMode) -> (TextEdit, TextData) {
|
||||
let view = TextView::new(TextBuffer::new(text), TextAttrs::default(), None);
|
||||
(TextEdit::new(view, mode), TextData::default())
|
||||
@@ -725,10 +658,6 @@ mod tests {
|
||||
assert_eq!(t.selection.unwrap().focus().index(), 0);
|
||||
}
|
||||
|
||||
/// The defect itself: an empty field's laid-out text is a zero-sized
|
||||
/// box, so a tap anywhere in it used to land "outside" and clear the
|
||||
/// selection -- leaving a focused composer that silently swallowed
|
||||
/// every keystroke (RUST.md's P0 box item 2).
|
||||
#[test]
|
||||
fn tapping_an_empty_field_places_a_caret_so_typing_lands() {
|
||||
let (mut t, mut d) = edit("", EditMode::MultiLine);
|
||||
@@ -738,10 +667,6 @@ mod tests {
|
||||
assert_eq!(content(&t), "hi");
|
||||
}
|
||||
|
||||
/// The half the fix had no reason to touch: a field that *does* hold
|
||||
/// text, tapped past the end of it (a multi-line composer's padding
|
||||
/// below the last line) keeps a caret rather than losing the one it
|
||||
/// had, and the caret lands at the nearest position -- the end.
|
||||
#[test]
|
||||
fn tapping_past_the_end_of_the_text_clamps_to_the_end() {
|
||||
let (mut t, mut d) = edit("abc", EditMode::MultiLine);
|
||||
@@ -749,8 +674,6 @@ mod tests {
|
||||
assert_eq!(t.selection.unwrap().focus().index(), 3);
|
||||
}
|
||||
|
||||
/// A drag still needs something to extend: with no previous selection
|
||||
/// there is nothing to drag from, and one must not be invented.
|
||||
#[test]
|
||||
fn dragging_without_a_previous_selection_selects_nothing() {
|
||||
let (mut t, mut d) = edit("abc", EditMode::MultiLine);
|
||||
@@ -783,8 +706,6 @@ mod tests {
|
||||
assert_eq!(content(&t), "");
|
||||
}
|
||||
|
||||
/// The IME's preedit path: each keystroke resends the whole composition,
|
||||
/// so `replace` has to remove exactly what it added last time.
|
||||
#[test]
|
||||
fn ime_preedit_replaces_its_own_previous_text() {
|
||||
let (mut t, mut d) = edit("", EditMode::SingleLine);
|
||||
@@ -797,10 +718,6 @@ mod tests {
|
||||
assert_eq!(content(&t), "に");
|
||||
}
|
||||
|
||||
/// `android/ime.rs`'s `set_composing_text` calls `replace` and expects
|
||||
/// the caret to land right after the inserted text, growing with it on
|
||||
/// every re-send -- the buffer-level half of RUST.md's P0 box ("doesn't
|
||||
/// enter it until I hit space, and also doesn't move cursor forward").
|
||||
#[test]
|
||||
fn composing_advances_the_caret_with_the_growing_text() {
|
||||
let (mut t, mut d) = edit("", EditMode::SingleLine);
|
||||
@@ -815,30 +732,17 @@ mod tests {
|
||||
assert_eq!(t.caret(), Some(3));
|
||||
}
|
||||
|
||||
/// The IME's `commitText` (`android_view::InputConnection::commit_text`'s
|
||||
/// default body): finish a composition in place, same as a real word
|
||||
/// boundary (a space) landing after Gboard's composing span.
|
||||
#[test]
|
||||
fn committing_composed_text_leaves_it_in_place_with_the_caret_after_it() {
|
||||
let (mut t, mut d) = edit("say ", EditMode::SingleLine);
|
||||
ctx(&mut t, &mut d).set_caret(4);
|
||||
ctx(&mut t, &mut d).replace(0, "hi");
|
||||
assert_eq!(content(&t), "say hi");
|
||||
// `finish_composing_text`/`commit_text` do not themselves touch the
|
||||
// buffer -- only the IME's own `compose_len` bookkeeping resets, in
|
||||
// `android/ime.rs`. Confirms the buffer already holds committed
|
||||
// text as plain, uncomposed content: a further `replace(0, " ")`
|
||||
// (the space that ends the word) appends rather than overwriting.
|
||||
ctx(&mut t, &mut d).replace(0, " ");
|
||||
assert_eq!(content(&t), "say hi ");
|
||||
assert_eq!(t.caret(), Some(7));
|
||||
}
|
||||
|
||||
/// `TextEditCtx::delete_byte_range` is `deleteSurroundingText`'s entry
|
||||
/// point once `android/ime.rs` has converted UTF-16 code units to
|
||||
/// bytes -- exercised directly here in bytes, since the UTF-16 math
|
||||
/// itself is `android/ime.rs`'s own `byte_to_utf16`/`utf16_to_byte`,
|
||||
/// outside this widget-only test module.
|
||||
#[test]
|
||||
fn delete_byte_range_removes_exactly_that_range() {
|
||||
let (mut t, mut d) = edit("hello world", EditMode::SingleLine);
|
||||
@@ -847,8 +751,6 @@ mod tests {
|
||||
assert_eq!(t.caret(), Some(5));
|
||||
}
|
||||
|
||||
/// `set_cursor_byte` is `setSelection`'s entry point -- collapses to a
|
||||
/// caret at the given byte offset regardless of any span that was there.
|
||||
#[test]
|
||||
fn set_cursor_byte_collapses_to_a_caret_there() {
|
||||
let (mut t, mut d) = edit("hello world", EditMode::SingleLine);
|
||||
@@ -868,8 +770,6 @@ mod tests {
|
||||
assert_eq!(t.selected_text().as_deref(), Some("b"));
|
||||
}
|
||||
|
||||
/// Collapsing a span with an unshifted arrow goes to the near end rather
|
||||
/// than stepping one character from the focus.
|
||||
#[test]
|
||||
fn an_unshifted_arrow_collapses_a_span_to_its_edge() {
|
||||
let (mut t, mut d) = edit("abcdef", EditMode::SingleLine);
|
||||
@@ -882,8 +782,6 @@ mod tests {
|
||||
assert_eq!(t.selection.unwrap().focus().index(), 6);
|
||||
}
|
||||
|
||||
/// Byte offsets, not character counts: a caret placed after a multi-byte
|
||||
/// character must not split it.
|
||||
#[test]
|
||||
fn multibyte_text_is_edited_by_byte_offset() {
|
||||
let (mut t, mut d) = edit("aé", EditMode::SingleLine);
|
||||
|
||||
@@ -16,7 +16,6 @@ pub struct Text {
|
||||
pub struct TextView {
|
||||
pub attrs: MutDetect<TextAttrs>,
|
||||
pub buf: MutDetect<TextBuffer>,
|
||||
// cache
|
||||
tex: Option<RenderedText>,
|
||||
width: Option<f32>,
|
||||
pub hint: Option<StrongWidget>,
|
||||
@@ -27,14 +26,10 @@ impl TextView {
|
||||
self.buf.is_empty()
|
||||
}
|
||||
|
||||
/// The width the text was last laid out against, so an editor asking for
|
||||
/// the layout gets the same wrapping the last draw used.
|
||||
pub fn wrap_width(&self) -> Option<f32> {
|
||||
self.width
|
||||
}
|
||||
}
|
||||
|
||||
impl TextView {
|
||||
pub fn new(buf: TextBuffer, attrs: TextAttrs, hint: Option<StrongWidget>) -> Self {
|
||||
Self {
|
||||
attrs: attrs.into(),
|
||||
@@ -45,8 +40,6 @@ impl TextView {
|
||||
}
|
||||
}
|
||||
|
||||
/// region where the text should be draw
|
||||
/// does not include extra height or width from weird unicode
|
||||
pub fn region(&self) -> UiRegion {
|
||||
self.tex()
|
||||
.map(|t| t.size)
|
||||
@@ -60,13 +53,6 @@ impl TextView {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// The atlas generation is part of the cache key, not a separate
|
||||
// invalidation path: a `RenderedText` is only meaningful against the
|
||||
// atlas its glyphs were placed in, and a renderer rebuild clears
|
||||
// that atlas out from under every widget at once
|
||||
// (`GlyphAtlas::clear`). Without this the text drawn before the
|
||||
// rebuild is re-emitted with the old atlas's coordinates and comes
|
||||
// back as fragments of whatever now occupies them.
|
||||
let generation = painter.atlas_generation();
|
||||
if width == self.width
|
||||
&& let Some(tex) = &self.tex
|
||||
@@ -78,11 +64,6 @@ impl TextView {
|
||||
}
|
||||
self.width = width;
|
||||
let tex = painter.render_text(&mut self.buf, &self.attrs, width);
|
||||
// Gated on `iris::diagnostics::trace_enabled` since 2026-09-07
|
||||
// (docs/RUST.md's review, D1): one line per text *shape* (a cache
|
||||
// miss), unconditional, is many per frame while rows compose --
|
||||
// see `android::view::IrisViewPeer::render`'s own doc for the same
|
||||
// finding on its two per-frame lines.
|
||||
if crate::diagnostics::trace_enabled() {
|
||||
log::debug!(
|
||||
target: "iris::frame",
|
||||
@@ -100,12 +81,6 @@ impl TextView {
|
||||
pub fn tex(&self) -> Option<&RenderedText> {
|
||||
self.tex.as_ref()
|
||||
}
|
||||
/// Draws within `painter.region()` and reports the size used -- what
|
||||
/// `desired_width`/`desired_height` used to answer separately, folded
|
||||
/// into the one draw (LAYOUT.md section 4): the shaped layout this
|
||||
/// reads is already memoized by width in `render`, so a second call at
|
||||
/// the same width (a redraw with nothing else changed) is a cache hit,
|
||||
/// not a re-shape.
|
||||
pub fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
let tex = self.render(painter);
|
||||
if self.is_blank()
|
||||
@@ -185,19 +160,6 @@ mod tests {
|
||||
use crate::layout_tests::TestRsc;
|
||||
use crate::prelude::*;
|
||||
|
||||
/// A renderer rebuild empties the glyph atlas under every widget at
|
||||
/// once (`iris_core::GlyphAtlas::clear`, called from
|
||||
/// `IrisViewPeer::surface_changed`'s new-renderer branch). Anything
|
||||
/// still holding a `RenderedText` from before then owns UV rectangles
|
||||
/// into a texture that no longer exists -- what Iris photographed on
|
||||
/// 2026-09-06 as every pre-resume glyph coming back as fragments while
|
||||
/// the text drawn after the resume was perfect.
|
||||
///
|
||||
/// The check is the atlas repopulating: `TextView::render`'s cache
|
||||
/// short-circuits before `TextData::place`, so without the generation
|
||||
/// in its key the second frame rasterises nothing and the atlas stays
|
||||
/// empty. (`Painter::glyphs`'s `debug_assert!` fires here too, which is
|
||||
/// the same finding from the submission side.)
|
||||
#[test]
|
||||
fn clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it() {
|
||||
let mut rsc = TestRsc {
|
||||
@@ -215,9 +177,6 @@ mod tests {
|
||||
let rasterised = rsc.ui.text.atlas.glyph_count();
|
||||
assert!(rasterised > 0, "the first frame rasterised no glyphs");
|
||||
|
||||
// Exactly what the new-renderer branch does, in order: empty the
|
||||
// atlas, then redraw everything (`resize` is what marks the tree
|
||||
// for a full redraw, and a real `surface_changed` always calls it).
|
||||
rsc.ui.text.atlas.clear();
|
||||
assert_eq!(rsc.ui.text.atlas.glyph_count(), 0);
|
||||
render.resize((800.0, 600.0));
|
||||
|
||||
@@ -84,25 +84,6 @@ widget_trait! {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap this widget in a [`ScrollArea`] that pans along `axis`, with
|
||||
/// the wheel and a finger drag both registered -- how anything with a
|
||||
/// fixed layout becomes scrollable.
|
||||
///
|
||||
/// `pin` says which end the area opens at and clings to as its content
|
||||
/// grows, and it is spelled out rather than defaulted because the two
|
||||
/// cases are not variations on each other: a composer wants the end,
|
||||
/// where what is being typed is, and a code fence opened at the end of
|
||||
/// its longest line, which is the middle of a word (seen in
|
||||
/// `iris/run-headless.sh phone`, 2026-09-08).
|
||||
///
|
||||
/// One method with the axis and the pin passed in, rather than the
|
||||
/// three named variants this used to be (Iris, 2026-09-08: "can we
|
||||
/// make both scroll methods become `.scrollable`, and it takes an axis
|
||||
/// and a pin instead of having two?"). A code fence pans across its
|
||||
/// own long lines exactly the way a transcript pans down its rows, so
|
||||
/// the two are one mechanism with the direction passed in --
|
||||
/// `DragArbiter::on` is the other half.
|
||||
///
|
||||
/// A [`LazySpan`] has an inherent `scrollable` of its own that this
|
||||
/// does not reach: it owns a controller already and must not be
|
||||
/// wrapped in an area that would slide it about as a lump.
|
||||
@@ -120,12 +101,6 @@ widget_trait! {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip to `shape` rather than to a plain box: `shape` is drawn
|
||||
/// behind this widget, filling the same region, and what clips is the
|
||||
/// primitive it drew -- so a rounded background and the corner its
|
||||
/// content is cut to are one rect, with no radius passed twice.
|
||||
/// Replaces `.masked().background(w)`, which drew the two but clipped
|
||||
/// to the box.
|
||||
fn masked_by<T>(self, shape: impl WidgetLike<Rsc, T>) -> impl WidgetFn<Rsc, Masked> {
|
||||
move |state| Masked {
|
||||
shape: Some(shape.add_strong(state)),
|
||||
|
||||
+1
-5
@@ -3,11 +3,7 @@ name = "tabs-ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
# The tabs example's widget tree, factored out of iris/examples/tabs/main.rs
|
||||
# so it can be built once and driven by either backend: the winit example
|
||||
# binary, and iris/android-app's cdylib. Its own crate rather than a pub
|
||||
# module of `iris` because it is demo content, not library surface -- see
|
||||
# RUST.md's I2.
|
||||
# Shared demo content for the desktop and Android example entry points.
|
||||
|
||||
[dependencies]
|
||||
iris = { path = ".." }
|
||||
@@ -1,13 +1,3 @@
|
||||
//! The tabs example's widget tree -- the five demo panes plus the message
|
||||
//! composer that exercises `TextEdit`. Factored out of
|
||||
//! `iris/examples/tabs/main.rs` (I2, RUST.md) so the same UI runs under
|
||||
//! both backends: the winit example binary calls `build` from
|
||||
//! `DefaultAppState::new`, and `iris-android-app`'s cdylib calls it from
|
||||
//! `AndroidAppState::new`. Nothing here mentions either backend by name --
|
||||
//! it only needs `Rsc: HasEvents` (for `.on(...)`) and `Rsc::State:
|
||||
//! FocusHost` (for `.attr::<Selectable>(())`), both of which every backend
|
||||
//! implements.
|
||||
|
||||
use iris::prelude::*;
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
@@ -181,12 +171,6 @@ where
|
||||
.on(CursorSense::HoverEnd, move |ctx, rsc| {
|
||||
ctx.widget(rsc).color = color;
|
||||
})
|
||||
// I4 (RUST.md): the tabs screen's only named controls, and the
|
||||
// ones the emulator step at the bottom of that box taps by
|
||||
// name -- `ui-trace record --do "tap 'pad'"` and so on. `.label`
|
||||
// slots into this chain like any other widget combinator
|
||||
// (`RefFnTag` in `core/src/widget/tag.rs`); it does not have to
|
||||
// be the last thing before `.add`.
|
||||
.label(label);
|
||||
(rect, wtext(label).size(30).text_align(Align::CENTER)).stack()
|
||||
};
|
||||
|
||||
@@ -1,41 +1,3 @@
|
||||
//! The CPU rounded-rect SDF and the shader's own must agree.
|
||||
//!
|
||||
//! LAYOUT.md's "Masks with a shape" turns on it: the fragment stage clips
|
||||
//! a masked subtree with `shader.wgsl`'s `rounded_rect_coverage`, and the
|
||||
//! hit test (`UiRenderState::mask_admits`) clips the *same* subtree with
|
||||
//! `iris_core::rounded_rect_coverage`, so a corner that cannot be tapped
|
||||
//! and a corner that is not drawn are the same corner only while the two
|
||||
//! functions answer the same. Nothing else checks that: both sides are
|
||||
//! individually plausible and drift shows up as a control that is a pixel
|
||||
//! or two off, which is exactly what nobody notices.
|
||||
//!
|
||||
//! So this runs **the real shader text**, lifted out of
|
||||
//! `iris_core::SHAPE_SHADER` by name rather than copied here, over a grid
|
||||
//! of points, and compares what came back with the Rust function at the
|
||||
//! same points. This is the only test in the workspace that needs a GPU;
|
||||
//! everything else about masks is layer 1 (docs/RUST.md's "Three test
|
||||
//! layers"). It fails rather than skips when there is no adapter, because
|
||||
//! a check that quietly did not run reads exactly like a check that
|
||||
//! passed.
|
||||
//!
|
||||
//! **It is a render pass, and it asks for `iris_core::device_limits()`,
|
||||
//! because those are the two things iris itself does.** The first version
|
||||
//! of this test was a compute pass, which meant asking for compute limits
|
||||
//! that `device_limits()` deliberately zeroes -- docs/RUST.md, 2026-09-05:
|
||||
//! nothing in `iris`/`iris-core` creates a `ComputePipeline` or writes a
|
||||
//! `@compute` stage, so the limits stopped being requested rather than a
|
||||
//! fallback being built for a capability nothing uses. A test that needs
|
||||
//! a capability the thing under test has never needed is testing the
|
||||
//! wrong device, which is reason enough.
|
||||
//!
|
||||
//! It is **not** why that version crashed; see [`vulkan_instance`] for
|
||||
//! what that crash actually was and why nothing here has to work around
|
||||
//! it any more.
|
||||
|
||||
// `OnceLock<wgpu::Instance>` needs `Instance: Sync`, and wgpu's type
|
||||
// graph is deep enough that proving it overflows rustc's default trait
|
||||
// recursion limit of 128. Nothing here is recursive; the limit is a
|
||||
// compile-time budget, and this is the documented way to raise it.
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
use std::sync::OnceLock;
|
||||
@@ -44,35 +6,16 @@ use iris_core::{SHAPE_SHADER, rounded_rect_coverage, util::Vec2};
|
||||
use pollster::FutureExt;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
/// The rect the grid is sampled against, in window pixels. Deliberately
|
||||
/// off the whole-pixel grid: the shader floors a primitive's corners, but
|
||||
/// `rounded_rect_coverage` is handed pixels either side of that and has to
|
||||
/// agree at fractional positions too -- the phone's 2.55 density puts
|
||||
/// nothing on a whole pixel.
|
||||
const TOP_LEFT: Vec2 = Vec2::new(10.5, 20.25);
|
||||
const BOT_RIGHT: Vec2 = Vec2::new(170.75, 90.0);
|
||||
|
||||
/// Radii spanning what the widgets actually ask for, plus the two edges of
|
||||
/// the function's own domain: a square corner, and one large enough that
|
||||
/// `min(edge, radius)` stops mattering.
|
||||
const RADII: [f32; 5] = [0.0, 0.75, 8.0, 20.0, 34.0];
|
||||
|
||||
/// The grid, as an attachment: one texel per probe point. `GRID_W` is a
|
||||
/// multiple of 64 so that a row of `R32Float` is 256-byte aligned, which
|
||||
/// is what `copy_texture_to_buffer` requires; at `STEP` this spans the
|
||||
/// rect above and about four pixels of margin on every side, so the
|
||||
/// feather is sampled rather than stepped over.
|
||||
const GRID_W: u32 = 384;
|
||||
const GRID_H: u32 = 192;
|
||||
const STEP: f32 = 0.5;
|
||||
const ORIGIN: Vec2 = Vec2::new(TOP_LEFT.x - 4.0, TOP_LEFT.y - 4.0);
|
||||
|
||||
/// f32 arithmetic in two compilers, not one: `length`/`sqrt` and
|
||||
/// `smoothstep` are each allowed a unit or two in the last place, and the
|
||||
/// GPU may contract a multiply-add the CPU does not. A coverage is in
|
||||
/// [0, 1], so this is about six decimal digits -- four orders of magnitude
|
||||
/// tighter than the half-pixel feather the hit test reads, which is what
|
||||
/// the agreement is actually for.
|
||||
const TOLERANCE: f32 = 1e-5;
|
||||
|
||||
#[test]
|
||||
@@ -114,7 +57,6 @@ fn mask_sdf_matches_the_shader() {
|
||||
corner stops being tappable where it is drawn.",
|
||||
);
|
||||
|
||||
// The half that would pass on a function returning a constant.
|
||||
assert!(
|
||||
inside > 0 && feather > 0 && outside > 0,
|
||||
"the grid never crossed an edge ({inside} in, {feather} on the feather, {outside} out), \
|
||||
@@ -122,18 +64,10 @@ fn mask_sdf_matches_the_shader() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The probe position of texel `(x, y)` -- the one place the mapping
|
||||
/// lives, so the CPU side and the fragment stage cannot walk different
|
||||
/// grids.
|
||||
fn probe_at(x: u32, y: u32) -> Vec2 {
|
||||
Vec2::new(ORIGIN.x + x as f32 * STEP, ORIGIN.y + y as f32 * STEP)
|
||||
}
|
||||
|
||||
/// `shader.wgsl`'s own `rounded_rect_coverage`, evaluated at every texel
|
||||
/// of an `R32Float` attachment: one fragment per grid point, read back
|
||||
/// whole. A fragment stage because that is the stage the function is
|
||||
/// really called from, so what this compares is the code path that draws
|
||||
/// rather than a second one built to be measurable.
|
||||
fn run_shader(gpu: &Gpu, radius: f32) -> Vec<f32> {
|
||||
let Gpu { device, queue, .. } = gpu;
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -141,10 +75,6 @@ fn run_shader(gpu: &Gpu, radius: f32) -> Vec<f32> {
|
||||
source: wgpu::ShaderSource::Wgsl(probe_source().into()),
|
||||
});
|
||||
|
||||
// R32Float, not an 8-bit colour format: a coverage quantised to 1/255
|
||||
// could not be compared against the CPU's at anything like TOLERANCE,
|
||||
// and the comparison would then be measuring the texture rather than
|
||||
// the two functions.
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("mask sdf coverage"),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -205,9 +135,6 @@ fn run_shader(gpu: &Gpu, radius: f32) -> Vec<f32> {
|
||||
}],
|
||||
});
|
||||
|
||||
// `copy_texture_to_buffer` wants each row 256-byte aligned; GRID_W is
|
||||
// chosen so that it already is, rather than padding and unpicking the
|
||||
// padding on the way out.
|
||||
let row_bytes = GRID_W * 4;
|
||||
assert_eq!(row_bytes % 256, 0, "GRID_W must keep rows 256-byte aligned");
|
||||
let out_size = u64::from(row_bytes) * u64::from(GRID_H);
|
||||
@@ -272,42 +199,17 @@ fn run_shader(gpu: &Gpu, radius: f32) -> Vec<f32> {
|
||||
coverage
|
||||
}
|
||||
|
||||
/// One `wgpu::Instance` for the process, created on first use and never
|
||||
/// destroyed.
|
||||
///
|
||||
/// **Why it is a static rather than a value the test owns.** Destroying
|
||||
/// the last `VkInstance` makes the Vulkan loader `dlclose` the ICD, and
|
||||
/// Mesa's ICD here registers a `pthread_key_create` destructor pointing
|
||||
/// into its own text without being linked `-z nodelete`. glibc then calls
|
||||
/// that destructor when the thread exits -- through an address that is no
|
||||
/// longer mapped. libtest runs every `#[test]` on a spawned thread, so a
|
||||
/// test that opens and closes an instance segfaults *after* printing its
|
||||
/// result, which reads exactly like the test failing. Measured
|
||||
/// 2026-09-08 with `scripts/rigs/gpu-probe`'s `teardown` bin: it
|
||||
/// needs no wgpu (raw `ash` does it too), no GPU work, and no device --
|
||||
/// an instance created and destroyed on a spawned thread is enough, and
|
||||
/// keeping any one instance alive is enough to prevent it.
|
||||
///
|
||||
/// Devices, queues and everything else drop normally; only the instance
|
||||
/// is held, which is what wgpu asks for anyway (one instance per
|
||||
/// process). So this costs one instance for the length of a test binary
|
||||
/// and buys ordinary drops everywhere else.
|
||||
fn vulkan_instance() -> &'static wgpu::Instance {
|
||||
static INSTANCE: OnceLock<wgpu::Instance> = OnceLock::new();
|
||||
INSTANCE.get_or_init(wgpu::Instance::default)
|
||||
}
|
||||
|
||||
/// The device this test draws with.
|
||||
struct Gpu {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
}
|
||||
|
||||
impl Gpu {
|
||||
/// Opens the device this test draws with, and reports which adapter
|
||||
/// answered, because that is not a detail here: a run on llvmpipe and
|
||||
/// a run on the host's GPU are otherwise indistinguishable in the
|
||||
/// log, and only one of them is a check of what the phone will do.
|
||||
fn open() -> Self {
|
||||
let instance = vulkan_instance();
|
||||
let adapter = instance
|
||||
@@ -326,7 +228,6 @@ impl Gpu {
|
||||
);
|
||||
let (device, queue) = adapter
|
||||
.request_device(&wgpu::DeviceDescriptor {
|
||||
// What iris itself asks for -- see this file's header.
|
||||
required_limits: iris_core::device_limits(),
|
||||
..Default::default()
|
||||
})
|
||||
@@ -336,8 +237,6 @@ impl Gpu {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the fragment stage needs to turn its own texel into a probe
|
||||
/// position: the rect being sampled, and where texel (0, 0) sits.
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Probe {
|
||||
@@ -349,11 +248,6 @@ struct Probe {
|
||||
_pad: [f32; 3],
|
||||
}
|
||||
|
||||
/// The probe module: the two functions **lifted from `shader.wgsl`
|
||||
/// itself**, plus an entry point that calls the outer one. Lifted rather
|
||||
/// than copied so there is nothing to keep in step -- an edit to the
|
||||
/// shader is what this test is for, and a copy here would be edited along
|
||||
/// with it.
|
||||
fn probe_source() -> String {
|
||||
format!(
|
||||
"{}\n{}\n\
|
||||
@@ -380,9 +274,6 @@ fn probe_source() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// One WGSL function's whole text, from its `fn` keyword to the `}` that
|
||||
/// closes its body, found by matching braces. Panics by name when the
|
||||
/// function is not there, which is what a rename looks like from here.
|
||||
fn wgsl_fn(name: &str) -> &'static str {
|
||||
let start = SHAPE_SHADER
|
||||
.find(&format!("fn {name}("))
|
||||
|
||||
Reference in new issue
Block a user