iris is the framework alone; the app is one crate in app-rust/
Iris: "the organization of the rust rewrite is a mess right now... there shouldn't be anything related to the app inside of iris. Iris is supposed to be the UI framework alone." And, on the crate count: "I'm confused why the app only code needs more than one crate though." Nine cargo workspaces become three, and the port's project code -- which sat in five places, four of them inside the framework -- becomes one crate, `ai-app`, in `app-rust/`: client-core -> app-rust/src/client iris/transcript-ui -> app-rust/src/ui iris/transcript-fixture -> app-rust/src/ui/fixture.rs + tests/ + touch/ iris/desktop-app -> app-rust/src/desktop + src/bin_desktop.rs iris/android-app -> app-rust/src/android + android-project/ android-shell -> app-rust/src/shell iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now mentions no session, transcript, setup or server anywhere. Only two of the old splits had a reason that survived reading. event-model stays a crate at the repo root because server/ depends on it too, so a crate is what makes the backend and the app agree by construction. The two Android .so names looked like a hard constraint -- a package produces one library artifact -- until P2 turned out to already plan merging those two Android apps into one; both faces now come out of libai_app.so, picked apart by features so `--no-default-features --features shell` keeps wgpu, parley and iris out of the Compose app's APK. docs/RUST.md's "One app crate" has the rest, including what each remaining feature is for. DECISIONS.md and SUBAGENTS.md move into docs/ with everything else. Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so, build-apk.sh produces an APK that installs and launches on this checkout's emulator (Gl ... virgl, as expected), and the phone-sized headless screenshot renders the transcript unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7b54aaf3c4
commit
a9312e9431
113 files changed
+23221
-2992
No files matched your search
Generated
+1795
-643
File diff suppressed because it is too large.
Load diff
+124
-13
@@ -8,35 +8,146 @@ edition.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
iris-core = { workspace = true }
|
iris-core = { workspace = true }
|
||||||
iris-macro = { workspace = true }
|
iris-macro = { workspace = true }
|
||||||
cosmic-text = { workspace = true }
|
parley = { workspace = true }
|
||||||
unicode-segmentation = { workspace = true }
|
swash = { workspace = true }
|
||||||
winit = { workspace = true }
|
|
||||||
arboard = { workspace = true, features = ["wayland-data-control"] }
|
|
||||||
pollster = { workspace = true }
|
pollster = { workspace = true }
|
||||||
wgpu = { workspace = true }
|
wgpu = { workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
|
accesskit = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
|
||||||
|
# For diagnostics visible through android_logger (or whatever logger the
|
||||||
|
# app crate installs) -- this crate never installs one itself. Not in the
|
||||||
|
# android-only block below any more: the lines that matter most are in
|
||||||
|
# shared widget code, which the host backend compiles too.
|
||||||
|
log = "0.4.34"
|
||||||
|
|
||||||
|
# winit everywhere except Android; android-view (below) is what stands in
|
||||||
|
# for it there. Both backends live in this crate (see `src/android/mod.rs`'s
|
||||||
|
# doc comment) but are never compiled together: winit's own Android support
|
||||||
|
# pulls in `android-activity`, which panics at compile time unless one of
|
||||||
|
# its own backend features is picked, and picking one is exactly what
|
||||||
|
# `iris-core` was kept free of (RUST.md's I0b). Confirmed by trying it
|
||||||
|
# 2026-09-05: `cargo ndk -t x86_64 -P 26 build -p iris` failed inside
|
||||||
|
# `android-activity` itself with "Either game-activity or native-activity
|
||||||
|
# must be enabled" before this split existed.
|
||||||
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
|
winit = { workspace = true }
|
||||||
|
arboard = { workspace = true, features = ["wayland-data-control"] }
|
||||||
|
# I4 (RUST.md): the desktop half of the AccessKit push, `winit`'s own
|
||||||
|
# adapter over `accesskit`. No pin needed the way android-view's rev is
|
||||||
|
# pinned -- this is an ordinary crates.io release with no local abort to
|
||||||
|
# track (that finding is Android-only, see below).
|
||||||
|
accesskit_winit = "0.34.0"
|
||||||
|
|
||||||
|
# Pinned to the exact commit RUST.md's E1 (2026-09-04) measured on this
|
||||||
|
# emulator -- real Vulkan rendering, a working `InputConnection`, and the
|
||||||
|
# accesskit-detach abort, all against this rev specifically. Advancing it
|
||||||
|
# wants re-running E1's checks, the same reason the nightly toolchain pin
|
||||||
|
# is dated rather than floating.
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
|
||||||
|
# I4 (RUST.md): the Android half of the AccessKit push, over android-view's
|
||||||
|
# `AccessibilityNodeProvider`. **0.8.0 carries the same detach-abort E1
|
||||||
|
# found on 0.4.0** (the `State` enum still never returns to `Inactive`,
|
||||||
|
# and `send_completed_event` still unwraps a Java exception) -- advancing
|
||||||
|
# the version is not the fix, so pinning to a specific rev buys nothing
|
||||||
|
# here the way it does for android-view itself. `android/view.rs`'s
|
||||||
|
# `raise_if_enabled` is the mitigation, carried from E1.
|
||||||
|
accesskit_android = "0.8.0"
|
||||||
|
# Not re-exported by android-view (only `jni` and `ndk` are), and needed
|
||||||
|
# for `android/insets.rs`'s own id -> state map -- the same reason
|
||||||
|
# android-view's own `PEER_MAP` carries one.
|
||||||
|
send_wrapper = "0.6.0"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# RUST.md's I5 "Where iris's frame time goes" diagnosis: pins the
|
||||||
|
# `wgpu::Instance` to `Backends::GL` instead of `Backends::PRIMARY`, so one
|
||||||
|
# build can be measured on either backend. A compile-time feature rather
|
||||||
|
# than an env var because nothing on this machine can hand an env var to an
|
||||||
|
# already-launched Android process (there is no `am start` environment and
|
||||||
|
# no system-property reader here to add one).
|
||||||
|
#
|
||||||
|
# **Not needed to get GLES in the emulator**, whatever the history here
|
||||||
|
# says: the emulator's guest has no hardware Vulkan at all, so an ordinary
|
||||||
|
# build's runtime fallback lands on GLES by itself (docs/RUST.md, "What the
|
||||||
|
# emulator gives a GPU app"). Keeping the emulator on the same binary the
|
||||||
|
# phone runs is the point. What this feature is still for is forcing GLES
|
||||||
|
# on a machine that *does* have Vulkan -- the desktop -- which is why
|
||||||
|
# `default/render.rs` reads it too:
|
||||||
|
# ./run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui \
|
||||||
|
# --features iris/force-gles
|
||||||
|
force-gles = []
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] }
|
tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] }
|
||||||
|
# The tabs example's widget tree. A dev-dependency cycle back to this
|
||||||
|
# package is fine -- cargo excludes dev-dependencies from the graph used
|
||||||
|
# to build the library itself, so this only matters for `--examples`.
|
||||||
|
tabs-ui = { path = "tabs-ui" }
|
||||||
|
# `tests/mask_sdf.rs` only: the grid it hands the GPU and the coverages it
|
||||||
|
# reads back. wgpu and pollster are ordinary dependencies already.
|
||||||
|
bytemuck = { workspace = true }
|
||||||
|
|
||||||
|
# Plain Instant-timed binaries, not criterion -- see benches/message_list.rs's
|
||||||
|
# header for why. `harness = false` opts out of the unstable `#[bench]`
|
||||||
|
# test-crate harness cargo would otherwise want, in favour of an ordinary
|
||||||
|
# `fn main()`.
|
||||||
|
[[bench]]
|
||||||
|
name = "message_list"
|
||||||
|
harness = false
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["core", "macro"]
|
members = [
|
||||||
|
"core",
|
||||||
|
"macro",
|
||||||
|
"tabs-ui",
|
||||||
|
"rig-input",
|
||||||
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
# Debug info is the reason a `cargo test --workspace` here was taking half
|
||||||
|
# an hour, and it is worth the paragraph. Measured 2026-09-08: with rustc's
|
||||||
|
# default `debug = true`, linking this workspace's test binaries wrote
|
||||||
|
# **~54 GB** (one single test binary's linker wrote 16.9 GB) and left an
|
||||||
|
# **88 GB** `target/`. Eight test binaries each statically link the whole
|
||||||
|
# wgpu + naga + winit + parley graph, and at the default every one of them
|
||||||
|
# gets a full copy of that graph's DWARF written into it. On a btrfs at 83%
|
||||||
|
# full the linkers then sat in `handle_reserve_ticket` -- uninterruptible,
|
||||||
|
# waiting on space reservation -- at about 20 MB/s between them, which is
|
||||||
|
# what "the tests are slow" actually was. Not CPU: the machine was 87% idle
|
||||||
|
# throughout.
|
||||||
|
#
|
||||||
|
# `line-tables-only` keeps what is actually read from a backtrace -- the
|
||||||
|
# file and line of every frame, which is what a panicking test prints and
|
||||||
|
# what gdb needs to name the frames of a segfault. What it gives up is
|
||||||
|
# inspecting variables in a debugger; when that is wanted, ask for it on
|
||||||
|
# the command line for that one run rather than paying for it on every
|
||||||
|
# build:
|
||||||
|
#
|
||||||
|
# RUSTFLAGS="-C debuginfo=2" cargo test -p iris --test whatever
|
||||||
|
[profile.dev]
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
|
# The tests are what this is really for; `cargo test` uses `dev` for
|
||||||
|
# dependencies and `test` for the test targets themselves, so setting only
|
||||||
|
# `dev` leaves the eight big binaries at the default.
|
||||||
|
[profile.test]
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
pollster = "0.4.0"
|
pollster = "1.0.1"
|
||||||
winit = "0.30.12"
|
winit = "0.30.13"
|
||||||
wgpu = "28.0.0"
|
wgpu = "30.0.1"
|
||||||
bytemuck = "1.23.1"
|
bytemuck = "1.25.2"
|
||||||
image = "0.25.6"
|
image = "0.25.10"
|
||||||
cosmic-text = "0.16.0"
|
parley = "0.11.1"
|
||||||
unicode-segmentation = "1.12.0"
|
swash = "0.2.10"
|
||||||
fxhash = "0.2.1"
|
fxhash = "0.2.1"
|
||||||
arboard = "3.6.1"
|
arboard = "3.6.1"
|
||||||
|
accesskit = "0.25.0"
|
||||||
iris-core = { path = "core" }
|
iris-core = { path = "core" }
|
||||||
iris-macro = { path = "macro" }
|
iris-macro = { path = "macro" }
|
||||||
tokio = "1.49.0"
|
tokio = "1.53.1"
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""AOSP's fling spline, transcribed independently of the Rust port.
|
||||||
|
|
||||||
|
This exists so the numbers in `sense.rs`'s `the_spline_matches_aosps_own_table`
|
||||||
|
and `a_flick_decelerates_the_way_aosp_says_it_does` are not the Rust code
|
||||||
|
grading its own homework. Every test iris's fling had before 2026-09-07
|
||||||
|
compared the curve with itself -- monotonic, signed, integrates to the closed
|
||||||
|
form -- and all of them passed while `distance_fraction(t)` was returning
|
||||||
|
exactly `t` (see `android_fling_spline`'s doc comment). Numbers checked into a
|
||||||
|
test have to come from somewhere else, and this is the somewhere else.
|
||||||
|
|
||||||
|
Transcribed by hand from, and only from:
|
||||||
|
|
||||||
|
* frameworks/base `core/java/android/widget/OverScroller.java`,
|
||||||
|
`SplineOverScroller`'s static initialiser, `getSplineDeceleration`,
|
||||||
|
`getSplineFlingDistance`, `getSplineFlingDuration` and `update`.
|
||||||
|
* androidx.compose.animation:animation:1.12.0 `SplineBasedDecay.kt`
|
||||||
|
(`computeSplineInfo`, `AndroidFlingSpline.flingPosition`) and
|
||||||
|
`FlingCalculator.kt` (`computeDeceleration`, `flingDistance`,
|
||||||
|
`flingDuration`, `FlingInfo.position`/`velocity`). The two agree line for
|
||||||
|
line, which is why iris ports one curve rather than two.
|
||||||
|
|
||||||
|
Run it with no arguments; it prints the table entries and the (velocity,
|
||||||
|
density, t) points the Rust tests assert on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NB_SAMPLES = 100
|
||||||
|
INFLEXION = 0.35
|
||||||
|
START_TENSION = 0.5
|
||||||
|
END_TENSION = 1.0
|
||||||
|
P1 = START_TENSION * INFLEXION
|
||||||
|
P2 = 1.0 - END_TENSION * (1.0 - INFLEXION)
|
||||||
|
|
||||||
|
# ViewConfiguration.getScrollFriction(), and SplineOverScroller's own
|
||||||
|
# "look and feel tuning" constant -- a different number in a different place
|
||||||
|
# of the same formula, which is the pair iris got the wrong way round once.
|
||||||
|
SCROLL_FRICTION = 0.015
|
||||||
|
TUNING = 0.84
|
||||||
|
GRAVITY_EARTH = 9.80665
|
||||||
|
INCHES_PER_METER = 39.37
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
DECELERATION_RATE = math.log(0.78) / math.log(0.9)
|
||||||
|
|
||||||
|
|
||||||
|
def spline_positions():
|
||||||
|
"""SPLINE_POSITION: distance fraction at each of 101 even time steps."""
|
||||||
|
position = [0.0] * (NB_SAMPLES + 1)
|
||||||
|
x_min = 0.0
|
||||||
|
for i in range(NB_SAMPLES):
|
||||||
|
alpha = i / NB_SAMPLES
|
||||||
|
x_max = 1.0
|
||||||
|
while True:
|
||||||
|
x = x_min + (x_max - x_min) / 2.0
|
||||||
|
coef = 3.0 * x * (1.0 - x)
|
||||||
|
# Solved on the P1/P2 curve...
|
||||||
|
tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x
|
||||||
|
if abs(tx - alpha) < 1e-5:
|
||||||
|
break
|
||||||
|
if tx > alpha:
|
||||||
|
x_max = x
|
||||||
|
else:
|
||||||
|
x_min = x
|
||||||
|
# ...and sampled on the tension curve.
|
||||||
|
position[i] = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x
|
||||||
|
position[NB_SAMPLES] = 1.0
|
||||||
|
return position
|
||||||
|
|
||||||
|
|
||||||
|
POSITION = spline_positions()
|
||||||
|
|
||||||
|
|
||||||
|
def fling_sample(t):
|
||||||
|
"""(distance fraction, velocity fraction) at time fraction `t`."""
|
||||||
|
t = min(max(t, 0.0), 1.0)
|
||||||
|
index = int(t * NB_SAMPLES)
|
||||||
|
if index >= NB_SAMPLES:
|
||||||
|
return 1.0, 0.0
|
||||||
|
t_inf = index / NB_SAMPLES
|
||||||
|
t_sup = (index + 1) / NB_SAMPLES
|
||||||
|
velocity_coef = (POSITION[index + 1] - POSITION[index]) / (t_sup - t_inf)
|
||||||
|
return POSITION[index] + (t - t_inf) * velocity_coef, velocity_coef
|
||||||
|
|
||||||
|
|
||||||
|
def physical_coefficient(density):
|
||||||
|
return GRAVITY_EARTH * INCHES_PER_METER * density * 160.0 * TUNING
|
||||||
|
|
||||||
|
|
||||||
|
def deceleration(velocity, density):
|
||||||
|
return math.log(
|
||||||
|
INFLEXION * abs(velocity) / (SCROLL_FRICTION * physical_coefficient(density))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fling_distance(velocity, density):
|
||||||
|
l = deceleration(velocity, density)
|
||||||
|
return (
|
||||||
|
SCROLL_FRICTION
|
||||||
|
* physical_coefficient(density)
|
||||||
|
* math.exp(DECELERATION_RATE / (DECELERATION_RATE - 1.0) * l)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fling_duration_s(velocity, density):
|
||||||
|
l = deceleration(velocity, density)
|
||||||
|
return math.exp(l / (DECELERATION_RATE - 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
def position_at(velocity, density, t_seconds):
|
||||||
|
d = fling_duration_s(velocity, density)
|
||||||
|
return fling_distance(velocity, density) * fling_sample(t_seconds / d)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def velocity_at(velocity, density, t_seconds):
|
||||||
|
d = fling_duration_s(velocity, density)
|
||||||
|
return fling_sample(t_seconds / d)[1] * fling_distance(velocity, density) / d
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("SPLINE_POSITION at a few indices (index: value)")
|
||||||
|
for i in (0, 1, 10, 25, 50, 75, 99, 100):
|
||||||
|
print(f" {i:3}: {POSITION[i]:.6f}")
|
||||||
|
print()
|
||||||
|
print("distance/velocity fraction at time fractions")
|
||||||
|
for t in (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0):
|
||||||
|
d, v = fling_sample(t)
|
||||||
|
print(f" t={t:<5} distance={d:.6f} velocity={v:.6f}")
|
||||||
|
print()
|
||||||
|
# 2.55 is Iris's Pixel 9 Pro XL (docs/bench/iris-phone-v2-2026-09-06.md);
|
||||||
|
# 2.75 is this checkout's emulator.
|
||||||
|
for density in (2.55, 2.75):
|
||||||
|
# 15250 is `transcript-fixture/touch/flick-120hz.touch`'s own
|
||||||
|
# release velocity (velocity_reference.py), so `phone_screen.rs`
|
||||||
|
# can bound the fling it produces from *here* rather than from the
|
||||||
|
# `FlingCalculator` under test (docs/REVIEW-2026-09-07.md's T1).
|
||||||
|
for velocity in (5000.0, 11064.0, 15250.0):
|
||||||
|
dur = fling_duration_s(velocity, density)
|
||||||
|
print(
|
||||||
|
f"density={density} v={velocity}: "
|
||||||
|
f"distance={fling_distance(velocity, density):.3f}px "
|
||||||
|
f"duration={dur:.4f}s"
|
||||||
|
)
|
||||||
|
# Deliberately not round fractions. The velocity coefficient is
|
||||||
|
# piecewise *constant* across each of the 100 samples, so it
|
||||||
|
# steps at t = k/100 and a test asserting on 0.75 is asserting
|
||||||
|
# on which side of a discontinuity the last float landed --
|
||||||
|
# which is genuinely different between Python and Rust and says
|
||||||
|
# nothing about the curve.
|
||||||
|
for frac in (0.125, 0.335, 0.505, 0.755):
|
||||||
|
t = frac * dur
|
||||||
|
print(
|
||||||
|
f" t={frac:>4} of duration ({t:.4f}s): "
|
||||||
|
f"pos={position_at(velocity, density, t):.3f}px "
|
||||||
|
f"vel={velocity_at(velocity, density, t):.3f}px/s"
|
||||||
|
)
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
//! On-demand benchmarks for iris's message-list scenario -- IRIS_TODO.md's
|
||||||
|
//! "Benchmarks" item, and RUST.md's I3. Never run by `cargo test`; run
|
||||||
|
//! explicitly with `cargo bench --bench message_list --release` or
|
||||||
|
//! `./run-bench.sh`.
|
||||||
|
//!
|
||||||
|
//! **Why a plain `Instant`-timed binary, not criterion.** Every scenario
|
||||||
|
//! here is really "how many `Widget::draw` calls and primitive rewrites did
|
||||||
|
//! this frame cost," which `UiRenderState::take_counters` already answers
|
||||||
|
//! exactly (see `iris/src/layout_tests.rs`, which this file's harness
|
||||||
|
//! mirrors). A short loop that times itself and prints the counters
|
||||||
|
//! alongside the wall time says everything criterion's warm-up/sampling/
|
||||||
|
//! outlier-removal machinery would add on top, for scenarios that are
|
||||||
|
//! fundamentally about a *count*, not a noisy microbenchmark distribution
|
||||||
|
//! -- and it avoids a new dependency this crate does not otherwise need.
|
||||||
|
//! Per the code rules, the plain option is also the one shorter to explain.
|
||||||
|
//!
|
||||||
|
//! **The list under test is `iris::widget::LazySpan` (RUST.md's I3), not a
|
||||||
|
//! `ScrollArea` over a `Span` of pre-built rows.** Earlier versions of this
|
||||||
|
//! file built their own giant `Span` and wrapped it in `ScrollArea`, which
|
||||||
|
//! meant (a)/(b)/(c) below were measuring "move one big child," never the
|
||||||
|
//! virtualised widget the app's transcript screen actually needs. `LazySpan`
|
||||||
|
//! still needs every row's *widget* built up front by the caller (its
|
||||||
|
//! module doc explains why: it only ever sees `&dyn Widget` through
|
||||||
|
//! `Painter`, so it cannot construct a row lazily on its own) -- what
|
||||||
|
//! virtualisation buys is that only the rows currently on screen are ever
|
||||||
|
//! *drawn*, which is what the draw/rewrite/move counters below are
|
||||||
|
//! measuring, not construction time.
|
||||||
|
//!
|
||||||
|
//! Scenarios (LAYOUT.md's O(1) move chain, lazy_span.rs's module doc, and
|
||||||
|
//! IRIS_TODO.md's "Benchmarks" wording):
|
||||||
|
//!
|
||||||
|
//! - (a) first-frame cost of a message list of N wrapped-text rows, some
|
||||||
|
//! with an image, for N = 100 / 1,000 / 10,000. With a virtualised list
|
||||||
|
//! this is expected to stop scaling with N once N exceeds a screenful --
|
||||||
|
//! the draw/rewrite counters below are the number that used to grow 10x
|
||||||
|
//! per 10x N and should not any more.
|
||||||
|
//! - (b) per-frame cost of scrolling that list -- must be O(1) moves, not
|
||||||
|
//! re-layout.
|
||||||
|
//! - (c) the input-box case: growing a fixed-height field at the bottom of
|
||||||
|
//! the screen must move the message list above it, not re-lay its rows.
|
||||||
|
//! Reports frame time *and* the draw/rewrite/move counters LAYOUT.md
|
||||||
|
//! section 8 defines.
|
||||||
|
//! - (d) insert-above-anchor: paging older history onto the front of an
|
||||||
|
//! already-scrolled list. `LazySpan::push_front` is an O(1) index update
|
||||||
|
//! (lazy_span.rs's module doc); this measures that none of the rows already
|
||||||
|
//! on screen are touched by it.
|
||||||
|
//! - (e) expand-a-row-holding-its-edge: growing one row's height with a
|
||||||
|
//! tap recorded near one of its edges (lazy_span.rs's `note_tap`) must move
|
||||||
|
//! only the rows on the far side of it, never redraw the ones already
|
||||||
|
//! correctly placed.
|
||||||
|
//!
|
||||||
|
//! - (g) redraw-one-big-text: a single text widget of N glyphs redrawn in
|
||||||
|
//! place, which is what a tool card rebuilt on a tap costs. Every one of
|
||||||
|
//! its primitives is freed and rewritten, and so renumbered in the
|
||||||
|
//! layer's draw order -- the pass that used to be O(N^2) there
|
||||||
|
//! (`UiRenderState::apply_free`, fixed 2026-09-08). The number to watch
|
||||||
|
//! is per-glyph: it must stay flat as N grows, not grow with it.
|
||||||
|
//!
|
||||||
|
//! (f), many images with zero steady-state bind-group creation, needs a
|
||||||
|
//! real `wgpu` device and lives in `iris/examples/bench_images.rs` instead,
|
||||||
|
//! driven through `run-headless.sh` -- see that file's header.
|
||||||
|
//!
|
||||||
|
//! `UiRenderState`/`Widgets` touch no GPU or window (as `layout_tests.rs`
|
||||||
|
//! notes), so everything here runs as an ordinary `--release` binary with
|
||||||
|
//! no compositor. Numbers are recorded in RUST.md's I3 box, not here --
|
||||||
|
//! this file is the rig, not the result.
|
||||||
|
|
||||||
|
use iris::prelude::*;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// The minimal `UiRsc` a benchmark needs -- identical in shape to
|
||||||
|
/// `layout_tests.rs`'s `TestRsc`.
|
||||||
|
struct BenchRsc {
|
||||||
|
ui: UiData,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiRsc for BenchRsc {
|
||||||
|
fn ui(&self) -> &UiData {
|
||||||
|
&self.ui
|
||||||
|
}
|
||||||
|
fn ui_mut(&mut self) -> &mut UiData {
|
||||||
|
&mut self.ui
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Long enough to force real wrapping at a phone-plausible column width, and
|
||||||
|
/// varied enough (no two rows byte-identical) that nothing can special-case
|
||||||
|
/// on repeated content.
|
||||||
|
const BODY: &str = "The quick brown fox jumps over the lazy dog. Iris lays \
|
||||||
|
out wrapped text by shaping once per width and caching the result, so a \
|
||||||
|
row that is offered the same width twice does not reshape. This sentence \
|
||||||
|
exists only to give a row enough text to wrap across several lines at a \
|
||||||
|
typical phone column width.";
|
||||||
|
|
||||||
|
/// One message row: a wrapped `Text`, and every `image_every`th row also an
|
||||||
|
/// `Image` beneath it -- a small in-memory RGBA square rather than a file,
|
||||||
|
/// so N=10,000 rows costs no disk I/O.
|
||||||
|
fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget {
|
||||||
|
let mut text = Text::new(format!("Message {i}: {BODY}"));
|
||||||
|
text.wrap = true;
|
||||||
|
let text = rsc.ui.widgets.add_strong(text).any();
|
||||||
|
|
||||||
|
if image_every > 0 && i.is_multiple_of(image_every) {
|
||||||
|
let img = image::DynamicImage::new_rgba8(64, 64);
|
||||||
|
let image_widget = image::<BenchRsc>(img)(rsc);
|
||||||
|
let image_widget = rsc.ui.widgets.add_strong(image_widget).any();
|
||||||
|
let mut row = Span::empty(Dir::DOWN);
|
||||||
|
row.push(text);
|
||||||
|
row.push(image_widget);
|
||||||
|
rsc.ui.widgets.add_strong(row).any()
|
||||||
|
} else {
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A virtualised `LazySpan` of `n` message rows, one in `image_every` of them
|
||||||
|
/// carrying an image (0 disables images entirely). Returns the list widget
|
||||||
|
/// (weak, so the caller can drive it) and the erased root to render.
|
||||||
|
fn build_message_list(
|
||||||
|
rsc: &mut BenchRsc,
|
||||||
|
n: usize,
|
||||||
|
image_every: usize,
|
||||||
|
) -> (WeakWidget<LazySpan>, StrongWidget) {
|
||||||
|
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
|
||||||
|
for i in 0..n {
|
||||||
|
let row = build_row(rsc, i, image_every);
|
||||||
|
list.push_back(LazyItem::new(i as u64, row));
|
||||||
|
}
|
||||||
|
let list = rsc.ui.widgets.add_strong(list);
|
||||||
|
// Driven through the span's own `ScrollController`, like every other
|
||||||
|
// scroll area in iris: what this measures has to be the path the app
|
||||||
|
// actually takes.
|
||||||
|
(list.weak(), list.any())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) {
|
||||||
|
println!(
|
||||||
|
"{label}: {:.2}ms draws={draws} rewrites={rewrites} moves={moves}",
|
||||||
|
elapsed.as_secs_f64() * 1000.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (a) First-frame cost of a message list of N rows.
|
||||||
|
fn bench_first_frame(n: usize) {
|
||||||
|
let mut rsc = BenchRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (_list, root) = build_message_list(&mut rsc, n, 20);
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 2000.0));
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
|
report(
|
||||||
|
&format!("(a) first frame, N={n}"),
|
||||||
|
elapsed,
|
||||||
|
draws,
|
||||||
|
rewrites,
|
||||||
|
moves,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (b) Per-frame cost of scrolling an already-laid-out list of N rows.
|
||||||
|
/// Warms up (one no-op tick, matching `ScrollArea`'s own need for it before an
|
||||||
|
/// ordinary Rust `layout_tests.rs` scrolling test becomes a same-size move
|
||||||
|
/// rather than a resize), then times a run of individual scroll ticks.
|
||||||
|
fn bench_scroll(n: usize, ticks: usize) {
|
||||||
|
let mut rsc = BenchRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (scroll, root) = build_message_list(&mut rsc, n, 20);
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 2000.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
render.take_counters();
|
||||||
|
|
||||||
|
let mut total = std::time::Duration::ZERO;
|
||||||
|
let mut total_draws = 0u64;
|
||||||
|
let mut total_rewrites = 0u64;
|
||||||
|
let mut total_moves = 0u64;
|
||||||
|
for _ in 0..ticks {
|
||||||
|
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-8.0);
|
||||||
|
let start = Instant::now();
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
total += start.elapsed();
|
||||||
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
|
total_draws += draws;
|
||||||
|
total_rewrites += rewrites;
|
||||||
|
total_moves += moves;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
&format!("(b) scroll, N={n}, {ticks} ticks (totals; expect draws/moves independent of N)"),
|
||||||
|
total,
|
||||||
|
total_draws,
|
||||||
|
total_rewrites,
|
||||||
|
total_moves,
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" per-tick average: {:.4}ms",
|
||||||
|
total.as_secs_f64() * 1000.0 / ticks as f64
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (c) The input-box case: a fixed-height field at the bottom of the screen
|
||||||
|
/// growing by a line at a time, with a message list of N rows filling the
|
||||||
|
/// rest of the screen above it. Growing the input shrinks the *offered*
|
||||||
|
/// height of the list container (a single widget, from the outer `Span`'s
|
||||||
|
/// point of view) without changing the width it offers its content -- so
|
||||||
|
/// the rows underneath, which only care about width, must not redraw; the
|
||||||
|
/// list's own re-registration of where its content sits is the one O(1)
|
||||||
|
/// move this is checking for.
|
||||||
|
fn bench_input_grows(n: usize, lines: usize) {
|
||||||
|
let mut rsc = BenchRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (scroll, list_root) = build_message_list(&mut rsc, n, 20);
|
||||||
|
let list_area = rsc.ui.widgets.add_strong(Sized {
|
||||||
|
inner: list_root,
|
||||||
|
x: None,
|
||||||
|
y: Some(rest(1.0)),
|
||||||
|
});
|
||||||
|
|
||||||
|
let line_height = 24.0;
|
||||||
|
let input_rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let input_area = rsc.ui.widgets.add_strong(Sized {
|
||||||
|
inner: input_rect.any(),
|
||||||
|
x: None,
|
||||||
|
y: Some(abs(line_height)),
|
||||||
|
});
|
||||||
|
|
||||||
|
let input_area_weak = input_area.weak();
|
||||||
|
let mut root_span = Span::empty(Dir::DOWN);
|
||||||
|
root_span.push(list_area.any());
|
||||||
|
root_span.push(input_area.any());
|
||||||
|
let root = rsc.ui.widgets.add_strong(root_span).any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 2000.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
render.take_counters();
|
||||||
|
|
||||||
|
let mut total = std::time::Duration::ZERO;
|
||||||
|
let mut total_draws = 0u64;
|
||||||
|
let mut total_rewrites = 0u64;
|
||||||
|
let mut total_moves = 0u64;
|
||||||
|
for line in 1..=lines {
|
||||||
|
rsc.ui.widgets.get_mut(&input_area_weak).unwrap().y =
|
||||||
|
Some(abs(line_height * (line + 1) as f32));
|
||||||
|
let start = Instant::now();
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
total += start.elapsed();
|
||||||
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
|
total_draws += draws;
|
||||||
|
total_rewrites += rewrites;
|
||||||
|
total_moves += moves;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
&format!(
|
||||||
|
"(c) input grows by {lines} lines above N={n} rows (totals; \
|
||||||
|
draws/rewrites must not scale with N)"
|
||||||
|
),
|
||||||
|
total,
|
||||||
|
total_draws,
|
||||||
|
total_rewrites,
|
||||||
|
total_moves,
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" per-line average: {:.4}ms",
|
||||||
|
total.as_secs_f64() * 1000.0 / lines as f64
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (d) Insert-above-anchor: the list is scrolled to its very first loaded
|
||||||
|
/// row (`jump_to_start`, an O(1) re-anchor) rather than left at the default
|
||||||
|
/// bottom, so a row prepended above it is genuinely "inserted above the
|
||||||
|
/// anchor" rather than merely far off-screen at the far end. Each
|
||||||
|
/// `push_front` is O(1) (lazy_span.rs's module doc: the anchor's slot is an
|
||||||
|
/// index, bumped by one) and, since the prepended rows never enter the
|
||||||
|
/// viewport, none of them should cost a draw either.
|
||||||
|
fn bench_insert_above_anchor(n: usize, inserts: usize) {
|
||||||
|
let mut rsc = BenchRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let (list, root) = build_message_list(&mut rsc, n, 20);
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 2000.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
rsc.ui.widgets.get_mut(&list).unwrap().jump_to_start();
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
render.take_counters();
|
||||||
|
|
||||||
|
let mut total = std::time::Duration::ZERO;
|
||||||
|
let mut total_draws = 0u64;
|
||||||
|
let mut total_rewrites = 0u64;
|
||||||
|
let mut total_moves = 0u64;
|
||||||
|
for i in 0..inserts {
|
||||||
|
// Older-history rows: distinct keys below every existing one, so a
|
||||||
|
// real caller's paging code (prepending an older page) is exactly
|
||||||
|
// what this loop does.
|
||||||
|
let row = build_row(&mut rsc, usize::MAX - i, 20);
|
||||||
|
rsc.ui
|
||||||
|
.widgets
|
||||||
|
.get_mut(&list)
|
||||||
|
.unwrap()
|
||||||
|
.push_front(LazyItem::new(i as u64, row));
|
||||||
|
let start = Instant::now();
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
total += start.elapsed();
|
||||||
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
|
total_draws += draws;
|
||||||
|
total_rewrites += rewrites;
|
||||||
|
total_moves += moves;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
&format!(
|
||||||
|
"(d) insert-above-anchor, N={n}, {inserts} pushes (totals; \
|
||||||
|
must not scale with N)"
|
||||||
|
),
|
||||||
|
total,
|
||||||
|
total_draws,
|
||||||
|
total_rewrites,
|
||||||
|
total_moves,
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" per-push average: {:.4}ms",
|
||||||
|
total.as_secs_f64() * 1000.0 / inserts as f64
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (e) Expand-a-row-holding-its-edge: one row (fixed-height, so its size is
|
||||||
|
/// directly controllable) is grown a little at a time, each time preceded
|
||||||
|
/// by `note_tap` aimed at its own top edge -- the exact mechanism lazy_span.rs's
|
||||||
|
/// module doc describes and its unit tests check for correctness. This
|
||||||
|
/// measures its *cost*: only the rows on the far side of the grown one
|
||||||
|
/// (below it, since the top edge is held) should ever move, and nothing
|
||||||
|
/// should be redrawn purely because the list overall got taller.
|
||||||
|
fn bench_expand_holds_edge(n: usize, growths: usize) {
|
||||||
|
let mut rsc = BenchRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
|
||||||
|
// Near the end (not the very last row) so it is already on screen
|
||||||
|
// under the list's default bottom-anchored placement, for every N --
|
||||||
|
// no scrolling needed to bring it into view before measuring.
|
||||||
|
let growable_index = n.saturating_sub(3);
|
||||||
|
let mut growable = None;
|
||||||
|
for i in 0..n {
|
||||||
|
if i == growable_index {
|
||||||
|
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let sized = rsc.ui.widgets.add_strong(Sized {
|
||||||
|
inner: rect.any(),
|
||||||
|
x: None,
|
||||||
|
y: Some(abs(40.0)),
|
||||||
|
});
|
||||||
|
growable = Some(sized.weak());
|
||||||
|
list.push_back(LazyItem::new(i as u64, sized.any()));
|
||||||
|
} else {
|
||||||
|
let row = build_row(&mut rsc, i, 20);
|
||||||
|
list.push_back(LazyItem::new(i as u64, row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let list = rsc.ui.widgets.add_strong(list);
|
||||||
|
let list_weak = list.weak();
|
||||||
|
let root = list.any();
|
||||||
|
let growable = growable.unwrap();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 2000.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
render.take_counters();
|
||||||
|
|
||||||
|
let mut total = std::time::Duration::ZERO;
|
||||||
|
let mut total_draws = 0u64;
|
||||||
|
let mut total_rewrites = 0u64;
|
||||||
|
let mut total_moves = 0u64;
|
||||||
|
let mut height = 40.0f32;
|
||||||
|
let key = growable_index as u64;
|
||||||
|
for _ in 0..growths {
|
||||||
|
height += 10.0;
|
||||||
|
if let Some((top, _bottom)) = rsc.ui.widgets.get(&list_weak).unwrap().extent(key) {
|
||||||
|
rsc.ui
|
||||||
|
.widgets
|
||||||
|
.get_mut(&list_weak)
|
||||||
|
.unwrap()
|
||||||
|
.note_tap(top + 1.0);
|
||||||
|
}
|
||||||
|
rsc.ui.widgets.get_mut(&growable).unwrap().y = Some(abs(height));
|
||||||
|
let start = Instant::now();
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
total += start.elapsed();
|
||||||
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
|
total_draws += draws;
|
||||||
|
total_rewrites += rewrites;
|
||||||
|
total_moves += moves;
|
||||||
|
}
|
||||||
|
report(
|
||||||
|
&format!(
|
||||||
|
"(e) expand-hold, N={n}, {growths} growths (totals; \
|
||||||
|
must not scale with N)"
|
||||||
|
),
|
||||||
|
total,
|
||||||
|
total_draws,
|
||||||
|
total_rewrites,
|
||||||
|
total_moves,
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" per-growth average: {:.4}ms",
|
||||||
|
total.as_secs_f64() * 1000.0 / growths as f64
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (g) One text widget of `chars` characters, redrawn in place `redraws`
|
||||||
|
/// times -- an open tool card whose content is rebuilt, or any widget
|
||||||
|
/// holding a lot of text that a tap changes.
|
||||||
|
///
|
||||||
|
/// A redraw frees every primitive the widget owned and writes fresh ones,
|
||||||
|
/// so every glyph is renumbered in its layer's draw order. Finding the
|
||||||
|
/// handle to renumber used to be a scan of everything the same widget
|
||||||
|
/// drew, which made one redraw quadratic in its own glyph count: 1.37s for
|
||||||
|
/// 51,200 glyphs on this machine, against 20ms to shape and rasterise the
|
||||||
|
/// same text. Print per-glyph rather than per-redraw, since flat is the
|
||||||
|
/// pass condition and a total says nothing without dividing it.
|
||||||
|
fn bench_redraw_big_text(chars: usize, redraws: usize) {
|
||||||
|
let mut rsc = BenchRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
// One character per glyph, and varied so nothing can collapse the
|
||||||
|
// string into a repeat.
|
||||||
|
let content: String = (0..chars)
|
||||||
|
.map(|i| char::from(b'a' + (i % 26) as u8))
|
||||||
|
.collect();
|
||||||
|
let mut text = Text::new(content);
|
||||||
|
text.wrap = true;
|
||||||
|
let text = rsc.ui.widgets.add_strong(text);
|
||||||
|
let handle = text.weak();
|
||||||
|
let root = text.any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((1080.0, 2000.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
render.take_counters();
|
||||||
|
|
||||||
|
let mut total = std::time::Duration::ZERO;
|
||||||
|
for _ in 0..redraws {
|
||||||
|
// Asking for the widget mutably is what marks it for redraw --
|
||||||
|
// the same path a caller changing its content takes.
|
||||||
|
rsc.ui.widgets.get_mut(&handle).unwrap();
|
||||||
|
let start = Instant::now();
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
total += start.elapsed();
|
||||||
|
}
|
||||||
|
let (draws, rewrites, moves, _shapes) = render.take_counters();
|
||||||
|
report(
|
||||||
|
&format!("(g) redraw one {chars}-glyph text, {redraws}x (totals)"),
|
||||||
|
total,
|
||||||
|
draws,
|
||||||
|
rewrites,
|
||||||
|
moves,
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" per redraw: {:.3}ms, per glyph: {:.4}us",
|
||||||
|
total.as_secs_f64() * 1000.0 / redraws as f64,
|
||||||
|
total.as_secs_f64() * 1_000_000.0 / (redraws * chars) as f64,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("iris message-list benchmark -- release build, this machine's CPU");
|
||||||
|
for &n in &[100usize, 1_000, 10_000] {
|
||||||
|
bench_first_frame(n);
|
||||||
|
}
|
||||||
|
for &n in &[100usize, 1_000, 10_000] {
|
||||||
|
bench_scroll(n, 200);
|
||||||
|
}
|
||||||
|
for &n in &[100usize, 1_000, 10_000] {
|
||||||
|
bench_input_grows(n, 40);
|
||||||
|
}
|
||||||
|
for &n in &[100usize, 1_000, 10_000] {
|
||||||
|
bench_insert_above_anchor(n, 200);
|
||||||
|
}
|
||||||
|
for &n in &[100usize, 1_000, 10_000] {
|
||||||
|
bench_expand_holds_edge(n, 40);
|
||||||
|
}
|
||||||
|
for &chars in &[1_000usize, 10_000, 50_000] {
|
||||||
|
bench_redraw_big_text(chars, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+96
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Turns `iris::input` debug lines -- from a phone's diagnostics report, or
|
||||||
|
from a report the layer-1 harness produced with tracing on
|
||||||
|
(`iris::diagnostics::set_trace(true)`) -- back into a `TouchScript` file
|
||||||
|
`iris::harness::Harness::replay` can play back at layer 1.
|
||||||
|
|
||||||
|
Why this exists: `docs/RUST.md`'s "Three test layers" box says the cheapest
|
||||||
|
layer that can answer a question wins, and a gesture that misbehaves on
|
||||||
|
Iris's phone is otherwise only describable in words. `iris::sense::
|
||||||
|
log_input_event`'s one line per platform event (Android's on_touch_event
|
||||||
|
once per `MotionEvent`, with historical samples inline; winit's once per
|
||||||
|
pointer `WindowEvent`; the harness's `touch`, once per script line) already
|
||||||
|
carries everything a `.touch` file's `t_ms action x y` needs -- this just
|
||||||
|
reads it back out and reconstructs the samples in order, expanding each
|
||||||
|
event's inline historical samples into their own `move` lines first (they
|
||||||
|
are always intermediate positions of a move, and Android documents them as
|
||||||
|
oldest first, which is also the order they appear in the line).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
report_to_touch.py < report.txt > replay.touch
|
||||||
|
report_to_touch.py report.txt > replay.touch
|
||||||
|
|
||||||
|
Only lines containing "iris input: action=..." are read; everything else in
|
||||||
|
the report (insets, frame timings, drag-release summaries) is ignored, so
|
||||||
|
this can be pointed at Copy report's whole clipboard text directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# The message half of `sense::log_input_event`'s format string, prefix-
|
||||||
|
# agnostic: a real report line also carries the ring's own
|
||||||
|
# `HH:MM:SS.mmm LEVEL target:` header (`LogLine::format`) or, forwarded
|
||||||
|
# through `ai_server::client_log`, a `[<source> <clock> #<seq>]` tag ahead
|
||||||
|
# of that -- neither of which this needs to understand, since `search`
|
||||||
|
# (not `match`) finds the marker wherever it starts.
|
||||||
|
LINE_RE = re.compile(
|
||||||
|
r"iris input: action=(?P<action>\w+) x=(?P<x>-?[0-9.]+) y=(?P<y>-?[0-9.]+) "
|
||||||
|
r"t=(?P<t>[0-9]+)ms history=(?P<hist>[0-9]+)(?P<rest>.*)$"
|
||||||
|
)
|
||||||
|
# One historical sample inside `rest`: `t:x,y`, space-separated, oldest first
|
||||||
|
# -- see `log_input_event`'s own doc for why order matters.
|
||||||
|
HIST_RE = re.compile(r"(?P<t>[0-9]+):(?P<x>-?[0-9.]+),(?P<y>-?[0-9.]+)")
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(value: float) -> str:
|
||||||
|
"""The number as `TouchScript::parse`'s own `f32::parse` would round-trip
|
||||||
|
it -- an integer without a trailing `.0` where the source was one
|
||||||
|
(every coordinate here is a physical pixel), `{:g}` otherwise so a
|
||||||
|
fractional value from a real device is not silently truncated."""
|
||||||
|
if value == int(value):
|
||||||
|
return str(int(value))
|
||||||
|
return f"{value:g}"
|
||||||
|
|
||||||
|
|
||||||
|
def convert(lines):
|
||||||
|
"""Every `iris::input` line, oldest first, expanded to one `(t_ms,
|
||||||
|
action, x, y)` tuple per touch sample -- a historical sample is always
|
||||||
|
an intermediate `move`, and the event's own sample keeps its real
|
||||||
|
action (`down`/`move`/`up`/`cancel`)."""
|
||||||
|
rows = []
|
||||||
|
for line in lines:
|
||||||
|
m = LINE_RE.search(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
hist_count = int(m.group("hist"))
|
||||||
|
hist_matches = list(HIST_RE.finditer(m.group("rest")))
|
||||||
|
if len(hist_matches) != hist_count:
|
||||||
|
print(
|
||||||
|
f"report_to_touch: {line.strip()!r} says history={hist_count} but "
|
||||||
|
f"holds {len(hist_matches)} samples -- skipped",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
for hm in hist_matches:
|
||||||
|
rows.append(
|
||||||
|
(int(hm.group("t")), "move", float(hm.group("x")), float(hm.group("y")))
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
(int(m.group("t")), m.group("action"), float(m.group("x")), float(m.group("y")))
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) > 2:
|
||||||
|
print("usage: report_to_touch.py [report.txt] < report.txt", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
text = open(sys.argv[1]) if len(sys.argv) == 2 else sys.stdin
|
||||||
|
for t_ms, action, x, y in convert(text):
|
||||||
|
print(f"{t_ms} {action} {_fmt(x)} {_fmt(y)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compose's touch velocity tracker, transcribed independently of the Rust port.
|
||||||
|
|
||||||
|
Same reason `fling_spline_reference.py` exists: the numbers checked into
|
||||||
|
`sense.rs`'s velocity tests must not be numbers the Rust produced. The old
|
||||||
|
estimator -- total motion over the sample span, an average -- passed every test
|
||||||
|
it had, because every one of those tests asserted the average's own definition
|
||||||
|
back at it. An average cannot tell an accelerating flick from a steady drag, and
|
||||||
|
that is exactly what Iris reported from the phone on 2026-09-07: "flinging now
|
||||||
|
actually works but is slower than Compose's immediately after releasing the
|
||||||
|
flick".
|
||||||
|
|
||||||
|
Transcribed by hand from, and only from, the `-sources.jar` of
|
||||||
|
**androidx.compose.ui:ui-android:1.12.0** and
|
||||||
|
**androidx.compose.foundation:foundation-android:1.12.0**
|
||||||
|
(dl.google.com/dl/android/maven2), read 2026-09-07:
|
||||||
|
|
||||||
|
* `androidx/compose/ui/input/pointer/util/VelocityTracker.kt` --
|
||||||
|
`VelocityTracker1D.calculateVelocity`, `polyFitLeastSquares`,
|
||||||
|
`calculateImpulseVelocity`, `kineticEnergyToVelocity`, and the constants
|
||||||
|
`HistorySize = 20`, `HorizonMilliseconds = 100`,
|
||||||
|
`AssumePointerMoveStoppedMilliseconds = 40`.
|
||||||
|
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.kt` --
|
||||||
|
`Lsq2VelocityTracker`, which is what the 2D `VelocityTracker` delegates to.
|
||||||
|
* `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.android.kt`
|
||||||
|
-- the `AndroidComposeUiFlags.isFrameworkVelocityTrackerEnabled` fork.
|
||||||
|
* `androidx/compose/ui/AndroidComposeUiFlags.android.kt` -- that flag's
|
||||||
|
default, which is `false`.
|
||||||
|
* `androidx/compose/foundation/gestures/Draggable.kt` -- `sendDragStart` /
|
||||||
|
`sendDragEvent` / `sendDragStopped`, i.e. *which* samples a touch drag
|
||||||
|
feeds the tracker and where the maximum-velocity clamp is applied.
|
||||||
|
* `androidx/compose/foundation/gestures/DifferentialVelocityTracker.kt` and
|
||||||
|
`NonTouchScrollingLogic.kt` -- the Impulse strategy's only caller.
|
||||||
|
* `androidx/compose/foundation/gestures/Scrollable.kt` --
|
||||||
|
`DefaultFlingBehavior.performFling`, for the minimum-velocity question.
|
||||||
|
|
||||||
|
**Which strategy a touch fling actually uses, since this was the surprise.**
|
||||||
|
`Strategy.Impulse` is *not* it. `scrollable`/`draggable` release through
|
||||||
|
`DragGestureNode.sendDragStopped`, which calls the 2D `VelocityTracker`; on
|
||||||
|
Android that is `Lsq2VelocityTracker` (the framework-tracker flag defaults to
|
||||||
|
false), which is two `VelocityTracker1D(strategy = Lsq2)` -- a degree-2
|
||||||
|
least-squares fit over **absolute positions**, whose velocity is the fitted
|
||||||
|
polynomial's derivative at the newest sample. Impulse is reached only through
|
||||||
|
`DifferentialVelocityTracker`, whose sole caller is `NonTouchScrollingLogic`:
|
||||||
|
mouse wheel and trackpad, never a finger. So this script transcribes Lsq2 and
|
||||||
|
iris ports Lsq2. `calculate_impulse_velocity` is here anyway, unused by the
|
||||||
|
printed points, because ruling it out by reading is cheaper than ruling it out
|
||||||
|
again next time somebody remembers "Compose uses impulse".
|
||||||
|
|
||||||
|
**Which samples a touch drag feeds it.** `sendDragStart` adds the DOWN change;
|
||||||
|
every subsequent MOVE, historical samples included, is added by `sendDragEvent`.
|
||||||
|
The **UP position is never added**: `Lsq2VelocityTracker.addPointerInputChange`
|
||||||
|
wraps its two `addPosition` calls in `if (!event.changedToUpIgnoreConsumed())`,
|
||||||
|
and all the UP branch does is reset the tracker when more than 40ms have passed
|
||||||
|
since the last MOVE (b/238654963). So a finger that stops before lifting reads
|
||||||
|
as a stop, not as a decelerating tail. Positions are the raw event positions,
|
||||||
|
so the touch slop is inside the motion the tracker sees even though the list
|
||||||
|
never scrolled by it.
|
||||||
|
|
||||||
|
Two of Compose's samples iris does *not* reproduce, both noted rather than
|
||||||
|
copied: pre-slop MOVEs (iris's `DragArbiter` is `Undecided` then too, so it
|
||||||
|
feeds none either -- these agree), and the single MOVE that *crosses* the slop,
|
||||||
|
which Compose drops because `sendDragStart` adds only the DOWN. iris feeds that
|
||||||
|
one, since it is a real measured position and dropping it would be copying a
|
||||||
|
quirk of where Compose happens to split its state machine.
|
||||||
|
|
||||||
|
**The clamps.** Maximum: `sendDragStopped` passes
|
||||||
|
`LocalViewConfiguration.maximumFlingVelocity`, which on Android is
|
||||||
|
`ViewConfiguration.getScaledMaximumFlingVelocity()` -- 8000 dp/s. Minimum:
|
||||||
|
there is **none** on this path. `ViewConfiguration.minimumFlingVelocity`
|
||||||
|
exists in Compose's `ViewConfiguration` interface but its only use in either
|
||||||
|
artifact is `NestedScrollInteropConnection`, for View interop.
|
||||||
|
`DefaultFlingBehavior.performFling` guards with `abs(initialVelocity) > 1f`
|
||||||
|
and says why in its own comment: "we need it since spline curve gives us
|
||||||
|
NaNs". 1 px/s, not 50 dp/s.
|
||||||
|
|
||||||
|
Run it with no arguments; it prints the sample sets and the velocities the
|
||||||
|
Rust tests assert on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
HISTORY_SIZE = 20
|
||||||
|
HORIZON_MILLISECONDS = 100.0
|
||||||
|
ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS = 40.0
|
||||||
|
MIN_SAMPLE_SIZE_LSQ2 = 3
|
||||||
|
|
||||||
|
# ViewConfiguration.getScaledMaximumFlingVelocity(), in dp/s.
|
||||||
|
MAXIMUM_FLING_VELOCITY_DP_S = 8000.0
|
||||||
|
# DefaultFlingBehavior.performFling's own threshold, in the units of the
|
||||||
|
# positions fed to the tracker -- pixels per second here.
|
||||||
|
FLING_MINIMUM_PX_S = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def poly_fit_least_squares(x, y, sample_count, degree):
|
||||||
|
"""`polyFitLeastSquares`: Gram-Schmidt QR, coefficients low order first."""
|
||||||
|
if degree < 1:
|
||||||
|
raise ValueError("The degree must be at positive integer")
|
||||||
|
if sample_count == 0:
|
||||||
|
raise ValueError("At least one point must be provided")
|
||||||
|
|
||||||
|
truncated_degree = sample_count - 1 if degree >= sample_count else degree
|
||||||
|
m = sample_count
|
||||||
|
n = truncated_degree + 1
|
||||||
|
|
||||||
|
# a[i][h] = x[h]**i, pre-multiplied by the (always 1.0) weight.
|
||||||
|
a = [[0.0] * m for _ in range(n)]
|
||||||
|
for h in range(m):
|
||||||
|
a[0][h] = 1.0
|
||||||
|
for i in range(1, n):
|
||||||
|
a[i][h] = a[i - 1][h] * x[h]
|
||||||
|
|
||||||
|
q = [[0.0] * m for _ in range(n)]
|
||||||
|
r = [[0.0] * n for _ in range(n)]
|
||||||
|
for j in range(n):
|
||||||
|
w = q[j]
|
||||||
|
w[:] = a[j][:m]
|
||||||
|
for i in range(j):
|
||||||
|
z = q[i]
|
||||||
|
dot = sum(w[h] * z[h] for h in range(m))
|
||||||
|
for h in range(m):
|
||||||
|
w[h] -= dot * z[h]
|
||||||
|
norm = math.sqrt(sum(v * v for v in w))
|
||||||
|
inverse_norm = 1.0 / max(norm, 1e-6)
|
||||||
|
for h in range(m):
|
||||||
|
w[h] *= inverse_norm
|
||||||
|
for i in range(n):
|
||||||
|
r[j][i] = 0.0 if i < j else sum(w[h] * a[i][h] for h in range(m))
|
||||||
|
|
||||||
|
coefficients = [0.0] * n
|
||||||
|
for i in range(n - 1, -1, -1):
|
||||||
|
c = sum(q[i][h] * y[h] for h in range(m))
|
||||||
|
for j in range(n - 1, i, -1):
|
||||||
|
c -= r[i][j] * coefficients[j]
|
||||||
|
coefficients[i] = c / r[i][i]
|
||||||
|
return coefficients
|
||||||
|
|
||||||
|
|
||||||
|
def kinetic_energy_to_velocity(kinetic_energy):
|
||||||
|
sign = 0.0 if kinetic_energy == 0.0 else math.copysign(1.0, kinetic_energy)
|
||||||
|
return sign * math.sqrt(2 * abs(kinetic_energy))
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_impulse_velocity(data_points, time, sample_count, is_data_differential):
|
||||||
|
"""`calculateImpulseVelocity` -- not on the touch path; see the module doc."""
|
||||||
|
work = 0.0
|
||||||
|
start = sample_count - 1
|
||||||
|
next_time = time[start]
|
||||||
|
for i in range(start, 0, -1):
|
||||||
|
current_time = next_time
|
||||||
|
next_time = time[i - 1]
|
||||||
|
if current_time == next_time:
|
||||||
|
continue
|
||||||
|
if is_data_differential:
|
||||||
|
delta = -data_points[i - 1]
|
||||||
|
else:
|
||||||
|
delta = data_points[i] - data_points[i - 1]
|
||||||
|
v_curr = delta / (current_time - next_time)
|
||||||
|
v_prev = kinetic_energy_to_velocity(work)
|
||||||
|
work += (v_curr - v_prev) * abs(v_curr)
|
||||||
|
if i == start:
|
||||||
|
work = work * 0.5
|
||||||
|
return kinetic_energy_to_velocity(work)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_velocity(samples):
|
||||||
|
"""`VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`.
|
||||||
|
|
||||||
|
`samples` is `(time_millis, position)` oldest first, at most the last
|
||||||
|
`HISTORY_SIZE` of which the circular buffer would still be holding.
|
||||||
|
Returns units per second.
|
||||||
|
"""
|
||||||
|
held = samples[-HISTORY_SIZE:]
|
||||||
|
if not held:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
data_points = []
|
||||||
|
time = []
|
||||||
|
newest_time, _ = held[-1]
|
||||||
|
previous_time = newest_time
|
||||||
|
for sample_time, sample_position in reversed(held):
|
||||||
|
age = float(newest_time - sample_time)
|
||||||
|
delta = abs(float(sample_time - previous_time))
|
||||||
|
# Lsq2 walks back sample to sample; only the non-differential
|
||||||
|
# Impulse branch compares every sample against the newest one.
|
||||||
|
previous_time = sample_time
|
||||||
|
if age > HORIZON_MILLISECONDS or delta > ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS:
|
||||||
|
break
|
||||||
|
data_points.append(sample_position)
|
||||||
|
time.append(-age)
|
||||||
|
if len(data_points) == HISTORY_SIZE:
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(data_points) < MIN_SAMPLE_SIZE_LSQ2:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
coefficients = poly_fit_least_squares(time, data_points, len(data_points), 2)
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
|
# The 2nd coefficient is the fitted polynomial's derivative at x = 0,
|
||||||
|
# which is the newest sample's timestamp. units/ms -> units/s.
|
||||||
|
return coefficients[1] * 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
def clamped(velocity, maximum):
|
||||||
|
"""`VelocityTracker1D.calculateVelocity(maximumVelocity)`."""
|
||||||
|
if velocity == 0.0 or math.isnan(velocity):
|
||||||
|
return 0.0
|
||||||
|
return min(velocity, maximum) if velocity > 0 else max(velocity, -maximum)
|
||||||
|
|
||||||
|
|
||||||
|
def average(samples):
|
||||||
|
"""The estimator being replaced: total motion over the span."""
|
||||||
|
if len(samples) < 2:
|
||||||
|
return 0.0
|
||||||
|
span = (samples[-1][0] - samples[0][0]) / 1000.0
|
||||||
|
if span <= 0.0:
|
||||||
|
return 0.0
|
||||||
|
return (samples[-1][1] - samples[0][1]) / span
|
||||||
|
|
||||||
|
|
||||||
|
# --- The three recorded sample sets the Rust tests assert on. ----------------
|
||||||
|
|
||||||
|
# 1. `transcript-fixture/touch/flick-120hz.touch`, as `DragGesture` feeds it:
|
||||||
|
# the DOWN position, then one position per MOVE. The UP at t=20 adds no
|
||||||
|
# sample (see the module doc), which is why the finger sitting still for its
|
||||||
|
# last 4ms does not drag the estimate down. y only; the flick is vertical.
|
||||||
|
FLICK_120HZ = [(0, 1000.0), (4, 1040.0), (8, 1086.0), (12, 1138.0), (16, 1196.0)]
|
||||||
|
|
||||||
|
# 2. A steady drag: 5px every 10ms for 100ms. A constant-velocity fit and an
|
||||||
|
# average must agree here -- this is the case that cannot tell the two
|
||||||
|
# estimators apart, which is why it is not the only one.
|
||||||
|
STEADY_DRAG = [(i * 10, float(i * 5)) for i in range(11)]
|
||||||
|
|
||||||
|
# 3. A flick that accelerates into the release: 10ms apart, deltas doubling.
|
||||||
|
# This is the case the average gets wrong, and the negative control for
|
||||||
|
# the port -- reverting to the average must fail this test and only this
|
||||||
|
# kind of test.
|
||||||
|
ACCELERATING_FLICK = [(0, 0.0), (10, 2.0), (20, 6.0), (30, 14.0), (40, 30.0), (50, 54.0)]
|
||||||
|
|
||||||
|
# 4. The two edges of the sample walk, checked here so the Rust asserts
|
||||||
|
# Compose's answer rather than iris's own reading of the rule.
|
||||||
|
# (a) An old, fast burst outside the 100ms horizon, then a slow steady
|
||||||
|
# drag: the burst must not leak into the estimate.
|
||||||
|
OLD_BURST_THEN_STEADY = [(0, 0.0)] + [(10 + i * 10, 1000.0 + i) for i in range(11)]
|
||||||
|
# (b) The finger stops for 48ms and then lifts. The gap exceeds
|
||||||
|
# AssumePointerMoveStopped, so the walk breaks after one sample and
|
||||||
|
# there is no fling -- what stops a "park it and let go" from
|
||||||
|
# flinging at whatever speed the finger arrived with.
|
||||||
|
STOPPED_BEFORE_RELEASE = [(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)]
|
||||||
|
|
||||||
|
# 5. `sense.rs`'s own `drag_gesture_tests`: what `DragGesture` feeds for a
|
||||||
|
# press and two move frames, which is the fewest a fit can use.
|
||||||
|
TWO_MOVE_FRAMES = [(0, 0.0), (8, 100.0), (16, 220.0)]
|
||||||
|
# ... and one move frame, which Compose cannot fit either.
|
||||||
|
ONE_MOVE_FRAME = [(0, 0.0), (8, 100.0)]
|
||||||
|
|
||||||
|
# The phone: 1080x2424 at content_scale 2.55.
|
||||||
|
PHONE_DENSITY = 2.55
|
||||||
|
|
||||||
|
|
||||||
|
def report(name, samples):
|
||||||
|
v = calculate_velocity(samples)
|
||||||
|
print(f"{name}:")
|
||||||
|
print(f" samples (t_ms, position): {samples}")
|
||||||
|
print(f" Lsq2 (Compose's touch path): {v:.4f} px/s")
|
||||||
|
print(f" average (the old estimator): {average(samples):.4f} px/s")
|
||||||
|
print(f" impulse (non-touch, for ref): ", end="")
|
||||||
|
held = list(reversed(samples[-HISTORY_SIZE:]))
|
||||||
|
newest = held[0][0]
|
||||||
|
print(
|
||||||
|
f"{calculate_impulse_velocity([p for _, p in held], [-(newest - t) for t, _ in held], len(held), False) * 1000.0:.4f} px/s"
|
||||||
|
)
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Compose 1.12.0 touch velocity: VelocityTracker1D, Strategy.Lsq2,")
|
||||||
|
print("non-differential (positions), HistorySize=20, Horizon=100ms,")
|
||||||
|
print("AssumePointerMoveStopped=40ms, minSampleSize=3.\n")
|
||||||
|
report("flick-120hz.touch", FLICK_120HZ)
|
||||||
|
report("steady drag (5px/10ms)", STEADY_DRAG)
|
||||||
|
report("accelerating flick (deltas 2,4,8,16,24 per 10ms)", ACCELERATING_FLICK)
|
||||||
|
|
||||||
|
report("old burst then steady 1px/10ms", OLD_BURST_THEN_STEADY)
|
||||||
|
report("stopped 48ms before release", STOPPED_BEFORE_RELEASE)
|
||||||
|
report("press and two move frames", TWO_MOVE_FRAMES)
|
||||||
|
report("press and one move frame", ONE_MOVE_FRAME)
|
||||||
|
|
||||||
|
print("Clamps:")
|
||||||
|
print(f" maximum: {MAXIMUM_FLING_VELOCITY_DP_S} dp/s")
|
||||||
|
print(
|
||||||
|
f" = {MAXIMUM_FLING_VELOCITY_DP_S * PHONE_DENSITY:.1f} px/s at the phone's density {PHONE_DENSITY}"
|
||||||
|
)
|
||||||
|
print(f" minimum: none on the fling path; DefaultFlingBehavior skips |v| <= {FLING_MINIMUM_PX_S} px/s")
|
||||||
|
print()
|
||||||
|
print("Two samples only (a press and one move, the phone's 120Hz worst case):")
|
||||||
|
print(f" Lsq2 needs 3 and answers {calculate_velocity(FLICK_120HZ[:2]):.4f} px/s")
|
||||||
+9
-2
@@ -4,9 +4,16 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
winit = { workspace = true }
|
|
||||||
wgpu = { workspace = true }
|
wgpu = { workspace = true }
|
||||||
|
# Only for `UiRenderNode::new`'s `push_error_scope`/`pop_error_scope` pair
|
||||||
|
# (renderer-creation error reporting, RUST.md's P0 phone-crash box) --
|
||||||
|
# `block_on` turns that one async pop into the same synchronous call shape
|
||||||
|
# `device_limits()`'s two callers already use for `request_adapter`/
|
||||||
|
# `request_device`, rather than making this crate's one entry point async.
|
||||||
|
pollster = { workspace = true }
|
||||||
bytemuck ={ workspace = true }
|
bytemuck ={ workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
cosmic-text = { workspace = true }
|
parley = { workspace = true }
|
||||||
|
swash = { workspace = true }
|
||||||
fxhash = { workspace = true }
|
fxhash = { workspace = true }
|
||||||
|
accesskit = { workspace = true }
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2014 Ryan L McIntyre
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
Binary file not shown.
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Rebuilds iris/core/assets/fonts/nerd_icons.ttf.
|
||||||
|
#
|
||||||
|
# iris draws its icons as glyphs in a Nerd Fonts subset it ships, rather
|
||||||
|
# than as ordinary Unicode out of whatever the platform resolved. Unicode's
|
||||||
|
# own geometric shapes are what this replaced: `tool.rs` set its disclosure
|
||||||
|
# mark with U+25B8/25BE/25B4, and once iris stopped bundling fonts
|
||||||
|
# (2026-09-07) Iris's phone drew an empty box for them and this VM drew a
|
||||||
|
# dot. UI_RULES: "don't rely on characters the platform might not have --
|
||||||
|
# ship the glyph or the asset rather than hoping."
|
||||||
|
#
|
||||||
|
# The whole symbols font is 3 MB for the handful below, so what is
|
||||||
|
# committed is a subset. Add a codepoint to GLYPHS *and* to
|
||||||
|
# `iris/core/src/icon.rs` (the two lists have to agree -- a codepoint in
|
||||||
|
# the Rust that this script did not subset is a glyph that silently isn't
|
||||||
|
# there), then run this and commit the result.
|
||||||
|
#
|
||||||
|
# Needs python3 and network access; fontTools is fetched into a temporary
|
||||||
|
# venv, so nothing has to be installed on the machine.
|
||||||
|
#
|
||||||
|
# The same arrangement as the Compose app's `app/build-icon-font.sh`, which
|
||||||
|
# this is copied from -- including the Mono face and the Material Design
|
||||||
|
# family, so an icon means the same thing in both apps. Copied rather than
|
||||||
|
# shared because most of it is the GLYPHS list, which has to differ: the
|
||||||
|
# point of subsetting is to ship only the codepoints one app draws.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Codepoint, then the Nerd Fonts glyph name it came from. Material Design
|
||||||
|
# Icons, as in the Compose app.
|
||||||
|
GLYPHS=(
|
||||||
|
U+F035D # md-menu_down -- a card that is open
|
||||||
|
U+F035F # md-menu_right -- a card that opens
|
||||||
|
U+F0360 # md-menu_up -- collapse this group again
|
||||||
|
)
|
||||||
|
|
||||||
|
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
|
||||||
|
here="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
out="$here/assets/fonts/nerd_icons.ttf"
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
echo "Fetching $url"
|
||||||
|
curl -fsSL -o "$work/nf.zip" "$url"
|
||||||
|
python3 -c 'import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])' "$work/nf.zip" "$work"
|
||||||
|
|
||||||
|
python3 -m venv "$work/venv"
|
||||||
|
"$work/venv/bin/pip" -q install fonttools
|
||||||
|
|
||||||
|
unicodes="$(IFS=,; echo "${GLYPHS[*]}")"
|
||||||
|
mkdir -p "$(dirname "$out")"
|
||||||
|
# The Mono face, where every glyph is one em wide and one em tall, so two
|
||||||
|
# icons at the same font size are the same size without either being given
|
||||||
|
# one -- the same reason the Compose app's script takes it. It is also what
|
||||||
|
# makes an icon's box predictable beside a line of text.
|
||||||
|
"$work/venv/bin/pyftsubset" "$work/SymbolsNerdFontMono-Regular.ttf" \
|
||||||
|
--unicodes="$unicodes" \
|
||||||
|
--layout-features= \
|
||||||
|
--drop-tables+=DSIG \
|
||||||
|
--output-file="$out"
|
||||||
|
cp "$work/LICENSE" "$here/assets/fonts/NERD_FONTS_LICENSE.txt"
|
||||||
|
|
||||||
|
echo "Wrote $out ($(stat -c %s "$out") bytes) with ${#GLYPHS[@]} glyphs"
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
use crate::{UiRsc, WidgetIdFn, WidgetLike, WeakWidget};
|
use crate::{UiRsc, WeakWidget, WidgetIdFn, WidgetLike};
|
||||||
|
|
||||||
pub trait WidgetAttr<Rsc, W: ?Sized> {
|
pub trait WidgetAttr<Rsc, W: ?Sized> {
|
||||||
type Input;
|
type Input;
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ type EventData<Rsc, E> = (E, Rc<dyn for<'a> EventFn<Rsc, <E as Event>::Data<'a>>
|
|||||||
pub struct TypeEventManager<Rsc: HasEvents, E: Event> {
|
pub struct TypeEventManager<Rsc: HasEvents, E: Event> {
|
||||||
// TODO: reduce visiblity!!
|
// TODO: reduce visiblity!!
|
||||||
pub active: HashMap<LayerId, HashMap<WidgetId, E::State>>,
|
pub active: HashMap<LayerId, HashMap<WidgetId, E::State>>,
|
||||||
|
/// This event's own input-wide state -- see [`Event::Global`].
|
||||||
|
pub global: E::Global,
|
||||||
map: HashMap<WidgetId, Vec<EventData<Rsc, E>>>,
|
map: HashMap<WidgetId, Vec<EventData<Rsc, E>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +109,7 @@ impl<Rsc: HasEvents, E: Event> Default for TypeEventManager<Rsc, E> {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
active: Default::default(),
|
active: Default::default(),
|
||||||
|
global: Default::default(),
|
||||||
map: Default::default(),
|
map: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,6 +138,18 @@ impl<Rsc: HasEvents + 'static, E: Event> TypeEventManager<Rsc, E> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The event lists this widget was registered with (`register`'s
|
||||||
|
/// `event` argument, one per call), without running anything. Lets a
|
||||||
|
/// caller ask "would this widget's registrations match the current
|
||||||
|
/// state" separately from actually dispatching to it -- used by
|
||||||
|
/// `sense.rs` to decide whether a widget genuinely consumes a scroll
|
||||||
|
/// or press this frame (so a lower layer can still receive it if not)
|
||||||
|
/// without that decision being conflated with "the cursor happens to
|
||||||
|
/// be over it," which is all `run_fn` running something tells you.
|
||||||
|
pub fn registered(&self, id: WidgetId) -> impl Iterator<Item = &E> {
|
||||||
|
self.map.get(&id).into_iter().flatten().map(|(e, _)| e)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run_fn<'a>(
|
pub fn run_fn<'a>(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: impl IdLike,
|
id: impl IdLike,
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ pub use rsc::*;
|
|||||||
pub trait Event: Sized + 'static + Clone {
|
pub trait Event: Sized + 'static + Clone {
|
||||||
type Data<'a>: Clone = ();
|
type Data<'a>: Clone = ();
|
||||||
type State: Default = ();
|
type State: Default = ();
|
||||||
|
/// State this event owns that belongs to no single widget -- what the
|
||||||
|
/// thing dispatching the event knows about the *input*, rather than
|
||||||
|
/// about a listener. `()` for almost every event; the cursor's is
|
||||||
|
/// `iris::sense::PointerInput` (which widget holds pointer capture,
|
||||||
|
/// and who is tracking the press in flight).
|
||||||
|
///
|
||||||
|
/// It lives here so that such state has one owner, reached by `&mut`
|
||||||
|
/// through the event manager, instead of being parked on whatever
|
||||||
|
/// structure a handler happens to be able to reach and guarded with a
|
||||||
|
/// lock. Iris asked for that on 2026-09-08, of the pointer capture
|
||||||
|
/// that used to sit in a `Mutex` on `UiRenderState`: "everything
|
||||||
|
/// global should be stored in the general input handler, not in
|
||||||
|
/// specific senses with locking stuff."
|
||||||
|
type Global: Default = ();
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
|
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
|
||||||
Some(data.clone())
|
Some(data.clone())
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
//! The icons iris draws, as codepoints in the Nerd Fonts subset it ships.
|
||||||
|
//!
|
||||||
|
//! **Why a bundled font rather than ordinary Unicode**: the disclosure
|
||||||
|
//! mark used to be U+25B8/25BE/25B4 out of whatever face the platform
|
||||||
|
//! resolved, and once iris stopped bundling fonts (DECISIONS.md,
|
||||||
|
//! 2026-09-07) Iris's phone drew an empty box for them and this VM drew a
|
||||||
|
//! dot. UI_RULES' answer is not to avoid glyphs but to ship them, which is
|
||||||
|
//! also what the Compose app has always done for its icons
|
||||||
|
//! (`app/build-icon-font.sh`, `NerdIcons.kt`) -- the same Material Design
|
||||||
|
//! family, so an icon means the same thing in both apps.
|
||||||
|
//!
|
||||||
|
//! **Why not vector assets or drawn shapes**: an icon beside a line of
|
||||||
|
//! text wants that line's size, colour and baseline, and text gets all
|
||||||
|
//! three for free. This replaced `iris::widget::mark`, which drew the
|
||||||
|
//! triangle into a texture: correct, but one shape, and every further icon
|
||||||
|
//! would have been another bespoke rasteriser.
|
||||||
|
//!
|
||||||
|
//! Each constant here has to have a matching codepoint in
|
||||||
|
//! `iris/core/build-icon-font.sh`'s `GLYPHS`; a codepoint here that the
|
||||||
|
//! script did not subset is a glyph that silently isn't there. The subset
|
||||||
|
//! is the font's **Mono** face, where every glyph is one em wide and one
|
||||||
|
//! em tall, so two icons at one font size are one size without either
|
||||||
|
//! being given one -- and why an icon looks smaller than text at the same
|
||||||
|
//! size, since the glyph is drawn inside that em rather than filling it.
|
||||||
|
//!
|
||||||
|
//! Draw one with [`crate::Family::Icons`]:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! text(icon::OPEN, 12.0, MUTED).family(Family::Icons)
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
/// `md-menu_down` -- a filled triangle pointing down: this card is open.
|
||||||
|
pub const OPEN: &str = "\u{F035D}";
|
||||||
|
|
||||||
|
/// `md-menu_right` -- pointing right: this card opens.
|
||||||
|
pub const CLOSED: &str = "\u{F035F}";
|
||||||
|
|
||||||
|
/// `md-menu_up` -- pointing up: fold this group of cards away again.
|
||||||
|
pub const COLLAPSE: &str = "\u{F0360}";
|
||||||
+1
-3
@@ -2,12 +2,9 @@
|
|||||||
#![feature(const_ops)]
|
#![feature(const_ops)]
|
||||||
#![feature(const_trait_impl)]
|
#![feature(const_trait_impl)]
|
||||||
#![feature(const_convert)]
|
#![feature(const_convert)]
|
||||||
#![feature(map_try_insert)]
|
|
||||||
#![feature(unboxed_closures)]
|
#![feature(unboxed_closures)]
|
||||||
#![feature(fn_traits)]
|
#![feature(fn_traits)]
|
||||||
#![feature(const_cmp)]
|
|
||||||
#![feature(const_destruct)]
|
#![feature(const_destruct)]
|
||||||
#![feature(portable_simd)]
|
|
||||||
#![feature(associated_type_defaults)]
|
#![feature(associated_type_defaults)]
|
||||||
#![feature(unsize)]
|
#![feature(unsize)]
|
||||||
#![feature(coerce_unsized)]
|
#![feature(coerce_unsized)]
|
||||||
@@ -22,6 +19,7 @@ mod render;
|
|||||||
mod ui;
|
mod ui;
|
||||||
mod widget;
|
mod widget;
|
||||||
|
|
||||||
|
pub mod icon;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|
||||||
pub use attr::*;
|
pub use attr::*;
|
||||||
|
|||||||
+5
-5
@@ -5,19 +5,19 @@ pub const trait UiNum {
|
|||||||
fn to_f32(self) -> f32;
|
fn to_f32(self) -> f32;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl const UiNum for f32 {
|
const impl UiNum for f32 {
|
||||||
fn to_f32(self) -> f32 {
|
fn to_f32(self) -> f32 {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl const UiNum for u32 {
|
const impl UiNum for u32 {
|
||||||
fn to_f32(self) -> f32 {
|
fn to_f32(self) -> f32 {
|
||||||
self as f32
|
self as f32
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl const UiNum for i32 {
|
const impl UiNum for i32 {
|
||||||
fn to_f32(self) -> f32 {
|
fn to_f32(self) -> f32 {
|
||||||
self as f32
|
self as f32
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ pub const fn vec2(x: impl const UiNum, y: impl const UiNum) -> Vec2 {
|
|||||||
Vec2::new(x.to_f32(), y.to_f32())
|
Vec2::new(x.to_f32(), y.to_f32())
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: const UiNum + Copy> const From<T> for Vec2 {
|
const impl<T: const UiNum + Copy> From<T> for Vec2 {
|
||||||
fn from(v: T) -> Self {
|
fn from(v: T) -> Self {
|
||||||
Self {
|
Self {
|
||||||
x: v.to_f32(),
|
x: v.to_f32(),
|
||||||
@@ -36,7 +36,7 @@ impl<T: const UiNum + Copy> const From<T> for Vec2 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: const UiNum, U: const UiNum> const From<(T, U)> for Vec2
|
const impl<T: const UiNum, U: const UiNum> From<(T, U)> for Vec2
|
||||||
where
|
where
|
||||||
(T, U): const Destruct,
|
(T, U): const Destruct,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ impl From<CardinalAlign> for Align {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl const From<RegionAlign> for UiVec2 {
|
const impl From<RegionAlign> for UiVec2 {
|
||||||
fn from(align: RegionAlign) -> Self {
|
fn from(align: RegionAlign) -> Self {
|
||||||
Self::rel(align.rel())
|
Self::rel(align.rel())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[derive(Copy, Clone, Eq, PartialEq)]
|
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
|
||||||
pub enum Axis {
|
pub enum Axis {
|
||||||
X,
|
X,
|
||||||
Y,
|
Y,
|
||||||
@@ -74,14 +74,14 @@ pub const trait AxisT {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct XAxis;
|
pub struct XAxis;
|
||||||
impl const AxisT for XAxis {
|
const impl AxisT for XAxis {
|
||||||
fn get() -> Axis {
|
fn get() -> Axis {
|
||||||
Axis::X
|
Axis::X
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct YAxis;
|
pub struct YAxis;
|
||||||
impl const AxisT for YAxis {
|
const impl AxisT for YAxis {
|
||||||
fn get() -> Axis {
|
fn get() -> Axis {
|
||||||
Axis::Y
|
Axis::Y
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,31 @@ pub struct Size {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct Len {
|
pub struct Len {
|
||||||
|
/// Physical pixels -- a raw device pixel, unaffected by the display's
|
||||||
|
/// density. Rare to want directly (a hairline border is the usual
|
||||||
|
/// case); most sizes should be `dp` instead. See `dp`'s own doc for why
|
||||||
|
/// the two are kept separate rather than one field a caller has to
|
||||||
|
/// remember to pre-multiply.
|
||||||
pub abs: f32,
|
pub abs: f32,
|
||||||
|
/// Density-independent pixels -- Android's `dp` / CSS's reference pixel
|
||||||
|
/// (1 unit = 1/160in), resolved against the display's density at
|
||||||
|
/// layout time (`apply_rest`'s `density` parameter) rather than at the
|
||||||
|
/// point a widget is built, since density is a property of the device
|
||||||
|
/// this ends up running on, not of the widget tree. This is the unit
|
||||||
|
/// IRIS_TODO.md's "a density-independent length unit" item asked for,
|
||||||
|
/// 2026-09-06: before it existed, every size in the tree was `abs`
|
||||||
|
/// (physical pixels), and the only way to make a 16px design draw at
|
||||||
|
/// the right *size* on a denser display was a single global multiply
|
||||||
|
/// applied to the whole rendered scene after layout -- which is also
|
||||||
|
/// what made text blurry (RUST.md's P0 box, "blurry ... glyphs drawn
|
||||||
|
/// at logical size and stretched by the scale"): a glyph rasterised at
|
||||||
|
/// 16 physical px and then stretched 3x by that global multiply is a
|
||||||
|
/// 48px area sampled from a 16px bitmap. Resolving `dp` per-length at
|
||||||
|
/// layout time instead means the font size handed to the text shaper
|
||||||
|
/// is already the physical size (`16.0.dp() * 3.0`), so the glyph
|
||||||
|
/// atlas rasterises at the display's real resolution and nothing
|
||||||
|
/// downstream needs to stretch anything.
|
||||||
|
pub dp: f32,
|
||||||
pub rel: f32,
|
pub rel: f32,
|
||||||
pub rest: f32,
|
pub rest: f32,
|
||||||
}
|
}
|
||||||
@@ -67,10 +91,10 @@ impl Size {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_uivec2(self) -> UiVec2 {
|
pub fn to_uivec2(self, density: f32) -> UiVec2 {
|
||||||
UiVec2 {
|
UiVec2 {
|
||||||
x: self.x.apply_rest(),
|
x: self.x.apply_rest(density),
|
||||||
y: self.y.apply_rest(),
|
y: self.y.apply_rest(density),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,26 +122,66 @@ impl Size {
|
|||||||
impl Len {
|
impl Len {
|
||||||
pub const ZERO: Self = Self {
|
pub const ZERO: Self = Self {
|
||||||
abs: 0.0,
|
abs: 0.0,
|
||||||
|
dp: 0.0,
|
||||||
rel: 0.0,
|
rel: 0.0,
|
||||||
rest: 0.0,
|
rest: 0.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const REST: Self = Self {
|
pub const REST: Self = Self {
|
||||||
abs: 0.0,
|
abs: 0.0,
|
||||||
|
dp: 0.0,
|
||||||
rel: 0.0,
|
rel: 0.0,
|
||||||
rest: 1.0,
|
rest: 1.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn apply_rest(&self) -> UiScalar {
|
/// Resolves to a `UiScalar`, folding `dp` into `abs` pixels against
|
||||||
|
/// `density` (physical pixels per dp -- 1.0 on a desktop or an
|
||||||
|
/// unscaled display, `content_scale` on Android; see `dp`'s field
|
||||||
|
/// doc). Every other component of `Len` is already resolution-
|
||||||
|
/// independent (`rel` is a fraction of the parent; `rest` becomes a
|
||||||
|
/// fraction too, below), so `density` only ever touches this one term.
|
||||||
|
pub fn apply_rest(&self, density: f32) -> UiScalar {
|
||||||
UiScalar {
|
UiScalar {
|
||||||
rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 },
|
rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 },
|
||||||
abs: self.abs,
|
abs: self.abs + self.dp * density,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same fold as [`Self::apply_rest`] but staying a `Len`, so
|
||||||
|
/// `rest` survives: `dp` becomes physical pixels and every other
|
||||||
|
/// component is left alone.
|
||||||
|
///
|
||||||
|
/// **A `Len` a widget *reports* must have been through this.** `dp` is
|
||||||
|
/// an input unit -- a number the widget author wrote -- and the
|
||||||
|
/// containers that consume a reported length read `abs`/`rel`/`rest`
|
||||||
|
/// directly (`Span::draw`'s placement arithmetic, `Pad`'s addition),
|
||||||
|
/// so a reported `dp` is silently worth zero. That is what made the
|
||||||
|
/// composer's bar collapse to nothing the moment its content grew past
|
||||||
|
/// `MaxSize`'s cap: the cap was `dp(168)` and was returned unresolved,
|
||||||
|
/// so the bar was given a slot of 0 and the field inside it was panned
|
||||||
|
/// out of a container measured at -63px. `UiRenderState::draw_inner`
|
||||||
|
/// debug-asserts the invariant after every `Widget::draw`.
|
||||||
|
pub fn fold_dp(&self, density: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
abs: self.abs + self.dp * density,
|
||||||
|
dp: 0.0,
|
||||||
|
rel: self.rel,
|
||||||
|
rest: self.rest,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn abs(abs: impl UiNum) -> Self {
|
pub fn abs(abs: impl UiNum) -> Self {
|
||||||
Self {
|
Self {
|
||||||
abs: abs.to_f32(),
|
abs: abs.to_f32(),
|
||||||
|
dp: 0.0,
|
||||||
|
rel: 0.0,
|
||||||
|
rest: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn dp(dp: impl UiNum) -> Self {
|
||||||
|
Self {
|
||||||
|
abs: 0.0,
|
||||||
|
dp: dp.to_f32(),
|
||||||
rel: 0.0,
|
rel: 0.0,
|
||||||
rest: 0.0,
|
rest: 0.0,
|
||||||
}
|
}
|
||||||
@@ -125,6 +189,7 @@ impl Len {
|
|||||||
pub fn rel(rel: impl UiNum) -> Self {
|
pub fn rel(rel: impl UiNum) -> Self {
|
||||||
Self {
|
Self {
|
||||||
abs: 0.0,
|
abs: 0.0,
|
||||||
|
dp: 0.0,
|
||||||
rel: rel.to_f32(),
|
rel: rel.to_f32(),
|
||||||
rest: 0.0,
|
rest: 0.0,
|
||||||
}
|
}
|
||||||
@@ -132,6 +197,7 @@ impl Len {
|
|||||||
pub fn rest(ratio: impl UiNum) -> Self {
|
pub fn rest(ratio: impl UiNum) -> Self {
|
||||||
Self {
|
Self {
|
||||||
abs: 0.0,
|
abs: 0.0,
|
||||||
|
dp: 0.0,
|
||||||
rel: 0.0,
|
rel: 0.0,
|
||||||
rest: ratio.to_f32(),
|
rest: ratio.to_f32(),
|
||||||
}
|
}
|
||||||
@@ -144,6 +210,15 @@ pub mod len_fns {
|
|||||||
pub fn abs(abs: impl UiNum) -> Len {
|
pub fn abs(abs: impl UiNum) -> Len {
|
||||||
Len {
|
Len {
|
||||||
abs: abs.to_f32(),
|
abs: abs.to_f32(),
|
||||||
|
dp: 0.0,
|
||||||
|
rel: 0.0,
|
||||||
|
rest: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn dp(dp: impl UiNum) -> Len {
|
||||||
|
Len {
|
||||||
|
abs: 0.0,
|
||||||
|
dp: dp.to_f32(),
|
||||||
rel: 0.0,
|
rel: 0.0,
|
||||||
rest: 0.0,
|
rest: 0.0,
|
||||||
}
|
}
|
||||||
@@ -151,6 +226,7 @@ pub mod len_fns {
|
|||||||
pub fn rel(rel: impl UiNum) -> Len {
|
pub fn rel(rel: impl UiNum) -> Len {
|
||||||
Len {
|
Len {
|
||||||
abs: 0.0,
|
abs: 0.0,
|
||||||
|
dp: 0.0,
|
||||||
rel: rel.to_f32(),
|
rel: rel.to_f32(),
|
||||||
rest: 0.0,
|
rest: 0.0,
|
||||||
}
|
}
|
||||||
@@ -158,14 +234,15 @@ pub mod len_fns {
|
|||||||
pub fn rest(ratio: impl UiNum) -> Len {
|
pub fn rest(ratio: impl UiNum) -> Len {
|
||||||
Len {
|
Len {
|
||||||
abs: 0.0,
|
abs: 0.0,
|
||||||
|
dp: 0.0,
|
||||||
rel: 0.0,
|
rel: 0.0,
|
||||||
rest: ratio.to_f32(),
|
rest: ratio.to_f32(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl_op!(Len Add add; abs rel rest);
|
impl_op!(Len Add add; abs dp rel rest);
|
||||||
impl_op!(Len Sub sub; abs rel rest);
|
impl_op!(Len Sub sub; abs dp rel rest);
|
||||||
|
|
||||||
impl_op!(Size Add add; x y);
|
impl_op!(Size Add add; x y);
|
||||||
impl_op!(Size Sub sub; x y);
|
impl_op!(Size Sub sub; x y);
|
||||||
@@ -187,6 +264,9 @@ impl std::fmt::Display for Len {
|
|||||||
if self.abs != 0.0 {
|
if self.abs != 0.0 {
|
||||||
write!(f, "{} abs;", self.abs)?;
|
write!(f, "{} abs;", self.abs)?;
|
||||||
}
|
}
|
||||||
|
if self.dp != 0.0 {
|
||||||
|
write!(f, "{} dp;", self.dp)?;
|
||||||
|
}
|
||||||
if self.rel != 0.0 {
|
if self.rel != 0.0 {
|
||||||
write!(f, "{} rel;", self.rel)?;
|
write!(f, "{} rel;", self.rel)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,13 +124,13 @@ impl Display for UiVec2 {
|
|||||||
impl_op!(UiVec2 Add add; x y);
|
impl_op!(UiVec2 Add add; x y);
|
||||||
impl_op!(UiVec2 Sub sub; x y);
|
impl_op!(UiVec2 Sub sub; x y);
|
||||||
|
|
||||||
impl const From<Vec2> for UiVec2 {
|
const impl From<Vec2> for UiVec2 {
|
||||||
fn from(abs: Vec2) -> Self {
|
fn from(abs: Vec2) -> Self {
|
||||||
Self::abs(abs)
|
Self::abs(abs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: const UiNum, U: const UiNum> const From<(T, U)> for UiVec2
|
const impl<T: const UiNum, U: const UiNum> From<(T, U)> for UiVec2
|
||||||
where
|
where
|
||||||
(T, U): const Destruct,
|
(T, U): const Destruct,
|
||||||
{
|
{
|
||||||
@@ -421,7 +421,7 @@ impl Display for UiRegion {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct PixelRegion {
|
pub struct PixelRegion {
|
||||||
pub top_left: Vec2,
|
pub top_left: Vec2,
|
||||||
pub bot_right: Vec2,
|
pub bot_right: Vec2,
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ pub struct Color<T> {
|
|||||||
pub a: T,
|
pub a: T,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Required by parley's `Brush`, which every text style is generic over. Opaque
|
||||||
|
/// black rather than transparent: a brush that was never set should be visible
|
||||||
|
/// and obviously unstyled, not invisible.
|
||||||
|
impl<T: ColorNum> Default for Color<T> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::BLACK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<T: ColorNum> Color<T> {
|
impl<T: ColorNum> Color<T> {
|
||||||
pub const BLACK: Self = Self::rgb(T::MIN, T::MIN, T::MIN);
|
pub const BLACK: Self = Self::rgb(T::MIN, T::MIN, T::MIN);
|
||||||
pub const WHITE: Self = Self::rgb(T::MAX, T::MAX, T::MAX);
|
pub const WHITE: Self = Self::rgb(T::MAX, T::MAX, T::MAX);
|
||||||
@@ -144,7 +153,7 @@ impl ColorNum for f32 {
|
|||||||
|
|
||||||
unsafe impl bytemuck::Pod for Color<u8> {}
|
unsafe impl bytemuck::Pod for Color<u8> {}
|
||||||
|
|
||||||
impl const F32Conversion for f32 {
|
const impl F32Conversion for f32 {
|
||||||
fn to(self) -> f32 {
|
fn to(self) -> f32 {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -153,7 +162,7 @@ impl const F32Conversion for f32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl const F32Conversion for u8 {
|
const impl F32Conversion for u8 {
|
||||||
fn to(self) -> f32 {
|
fn to(self) -> f32 {
|
||||||
self as f32 / 255.0
|
self as f32 / 255.0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
use std::ops::{Index, IndexMut};
|
use std::ops::{Index, IndexMut};
|
||||||
|
|
||||||
use crate::{
|
use crate::{render::LayerOrder, util::to_mut};
|
||||||
render::{MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst, Primitives},
|
|
||||||
util::to_mut,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub type LayerId = usize;
|
pub type LayerId = usize;
|
||||||
|
|
||||||
@@ -39,7 +36,10 @@ struct Child {
|
|||||||
tail: usize,
|
tail: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type PrimitiveLayers = Layers<Primitives>;
|
/// The draw order of every layer. The primitives themselves live in one
|
||||||
|
/// arena beside this (`UiRenderState::primitives`); a layer names the
|
||||||
|
/// slots it draws, which is what its vertex buffer is.
|
||||||
|
pub type PrimitiveLayers = Layers<LayerOrder>;
|
||||||
|
|
||||||
impl<T: Default> Layers<T> {
|
impl<T: Default> Layers<T> {
|
||||||
pub fn new() -> Layers<T> {
|
pub fn new() -> Layers<T> {
|
||||||
@@ -119,20 +119,6 @@ impl<T: Default> Layers<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PrimitiveLayers {
|
|
||||||
pub fn write<P: Primitive>(
|
|
||||||
&mut self,
|
|
||||||
layer: LayerId,
|
|
||||||
info: PrimitiveInst<P>,
|
|
||||||
) -> PrimitiveHandle {
|
|
||||||
self[layer].write(layer, info)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
|
|
||||||
self[h.layer].free(h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Default> Default for Layers<T> {
|
impl<T: Default> Default for Layers<T> {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
|
|||||||
+725
-139
@@ -1,60 +1,444 @@
|
|||||||
use crate::{Align, RegionAlign, TextureHandle, Textures, UiColor, util::Vec2};
|
use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiColor, util::Vec2};
|
||||||
use cosmic_text::{
|
use parley::{
|
||||||
Attrs, AttrsList, Buffer, CacheKey, Color, Family, FontSystem, Metrics, Placement, SwashCache,
|
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
|
||||||
SwashContent,
|
GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
|
||||||
|
fontique::Blob,
|
||||||
|
};
|
||||||
|
use std::ops::Range;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use swash::{
|
||||||
|
FontRef,
|
||||||
|
scale::{Render, ScaleContext, Source, StrikeWith},
|
||||||
|
zeno::{Format, Vector},
|
||||||
};
|
};
|
||||||
use image::{DynamicImage, GenericImageView, RgbaImage};
|
|
||||||
use std::simd::{Simd, num::SimdUint};
|
|
||||||
|
|
||||||
/// TODO: properly wrap this
|
/// The icon font iris ships: the Nerd Fonts Symbols **Mono** subset built
|
||||||
pub mod text_lib {
|
/// by `iris/core/build-icon-font.sh`, holding only the codepoints
|
||||||
pub use cosmic_text::*;
|
/// `crate::icon` names (992 bytes for three glyphs today).
|
||||||
|
///
|
||||||
|
/// This is the one font bundled here, and it is not a text font: body and
|
||||||
|
/// monospace text still come from the platform's own collection
|
||||||
|
/// (DECISIONS.md, 2026-09-07). An icon is the opposite case -- a small,
|
||||||
|
/// closed set of codepoints no system font is guaranteed to have -- which
|
||||||
|
/// is the same division the Compose app makes.
|
||||||
|
const NERD_ICONS: &[u8] = include_bytes!("../../assets/fonts/nerd_icons.ttf");
|
||||||
|
|
||||||
|
/// What starting up found about text rendering, for the on-screen
|
||||||
|
/// Diagnostics page and the one startup log line (RUST.md's P0 box, "log
|
||||||
|
/// once at startup ... the number of font families found, the default
|
||||||
|
/// family resolved"). Built once by `TextData::font_diagnostics` --
|
||||||
|
/// `Default::default` still exists for callers (tests, examples) that
|
||||||
|
/// don't need the report.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct FontDiagnostics {
|
||||||
|
/// `Collection::family_names().count()` after registering the bundled
|
||||||
|
/// fonts -- system families plus the two bundled ones.
|
||||||
|
pub families_found: usize,
|
||||||
|
/// The family `GenericFamily::SansSerif` resolves to first -- the
|
||||||
|
/// bundled "Noto Sans" unless registration itself failed.
|
||||||
|
pub default_family: Option<String>,
|
||||||
|
/// The family `GenericFamily::Monospace` resolves to first.
|
||||||
|
pub default_mono_family: Option<String>,
|
||||||
|
/// One resolved family name per style axis this crate actually uses
|
||||||
|
/// (`SpanStyle::bold`/`italic`), so a report can say plainly whether a
|
||||||
|
/// bold/italic request is landing on a real face rather than being
|
||||||
|
/// silently absorbed by whatever the sans-serif default resolves to
|
||||||
|
/// for every weight (RUST.md's P0 box, "bold words render as blank
|
||||||
|
/// gaps" -- a family that resolves but has no distinct bold face is
|
||||||
|
/// exactly what produced that).
|
||||||
|
pub regular_resolved: Option<String>,
|
||||||
|
pub bold_resolved: Option<String>,
|
||||||
|
pub italic_resolved: Option<String>,
|
||||||
|
pub mono_resolved: Option<String>,
|
||||||
|
/// The family the bundled icon font registered under, or `None` if
|
||||||
|
/// registering it failed. Reported rather than assumed: it is the one
|
||||||
|
/// font iris ships, so `None` is a broken build and must not look
|
||||||
|
/// like a device that happens to lack a face.
|
||||||
|
pub icon_family: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything text needs that outlives one string: the font collection, the
|
||||||
|
/// layout scratch space, the glyph rasteriser and the atlas they fill.
|
||||||
pub struct TextData {
|
pub struct TextData {
|
||||||
pub font_system: FontSystem,
|
pub font_cx: FontContext,
|
||||||
pub swash_cache: SwashCache,
|
pub layout_cx: LayoutContext<UiColor>,
|
||||||
glyph_cache: Vec<(Placement, CacheKey, Color)>,
|
scale_cx: ScaleContext,
|
||||||
|
pub atlas: GlyphAtlas,
|
||||||
|
/// Physical pixels per dp -- a second copy of
|
||||||
|
/// `UiRenderState::density`, kept here too because `TextEditCtx::layout`
|
||||||
|
/// (cursor movement and hit-testing, `widget/text/edit.rs`) shapes text
|
||||||
|
/// from an event callback that has a `TextData` but no `Painter`, so it
|
||||||
|
/// has nowhere else to read the display's density from. Both copies are
|
||||||
|
/// set together, from the one place either backend learns the real
|
||||||
|
/// value (`android::view::new_peer`); this is the same accepted
|
||||||
|
/// duplication as `AndroidRenderer::content_scale`; a single source of
|
||||||
|
/// truth would mean carrying a `Painter` (or output size) into every
|
||||||
|
/// input handler for the sake of one field.
|
||||||
|
pub density: f32,
|
||||||
|
/// The family name [`NERD_ICONS`] registered under, which is what
|
||||||
|
/// [`Family::Icons`] resolves to. `None` only if registering the
|
||||||
|
/// bundled font failed, which is a broken build rather than a
|
||||||
|
/// platform difference -- said in the startup diagnostics rather than
|
||||||
|
/// silently drawn as tofu.
|
||||||
|
pub icon_family: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TextData {
|
impl Default for TextData {
|
||||||
|
/// Text comes entirely from the platform's own font collection --
|
||||||
|
/// `FontContext::new()` builds a `fontique::Collection` with
|
||||||
|
/// `CollectionOptions::system_fonts` on by default, which is real
|
||||||
|
/// discovery on both targets this crate ships on: Android's backend
|
||||||
|
/// parses `/system/fonts` and `/system/etc/fonts.xml` and maps
|
||||||
|
/// `SansSerif`/`SystemUi` to `["Roboto Flex", "Roboto", "Noto Sans"]`
|
||||||
|
/// and `Monospace` to the platform's `"monospace"` alias; the desktop
|
||||||
|
/// build's backend is fontconfig. No font is bundled or registered
|
||||||
|
/// here -- see DECISIONS.md's 2026-09-07 entry for why (matching what
|
||||||
|
/// the Compose app does: it takes body/monospace text from
|
||||||
|
/// `FontFamily.Default`/`FontFamily.Monospace`, i.e. Android's Roboto
|
||||||
|
/// and its platform monospace face, and ships no text font of its own,
|
||||||
|
/// only its committed Nerd Fonts icon subset for fixed glyphs).
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
let mut font_cx = FontContext::new();
|
||||||
|
patch_android_monospace(&mut font_cx);
|
||||||
|
let icon_family = register_icon_font(&mut font_cx);
|
||||||
Self {
|
Self {
|
||||||
font_system: FontSystem::new(),
|
font_cx,
|
||||||
swash_cache: SwashCache::new(),
|
layout_cx: LayoutContext::new(),
|
||||||
glyph_cache: Default::default(),
|
scale_cx: ScaleContext::new(),
|
||||||
|
atlas: GlyphAtlas::default(),
|
||||||
|
density: 1.0,
|
||||||
|
icon_family,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
/// Registers the bundled icon font as an ordinary named family and
|
||||||
|
/// answers the name it registered under -- read back from the collection
|
||||||
|
/// rather than written down here, so the name cannot drift from the file
|
||||||
|
/// (`build-icon-font.sh` takes whatever face the Nerd Fonts release
|
||||||
|
/// ships).
|
||||||
|
///
|
||||||
|
/// A *named* family rather than a generic one: nothing should fall back
|
||||||
|
/// to it for ordinary text, and nothing should fall back out of it for an
|
||||||
|
/// icon -- a system face that happens to have one of these codepoints
|
||||||
|
/// would draw somebody else's picture.
|
||||||
|
fn register_icon_font(font_cx: &mut FontContext) -> Option<String> {
|
||||||
|
let blob = Blob::new(Arc::new(NERD_ICONS));
|
||||||
|
let id = font_cx
|
||||||
|
.collection
|
||||||
|
.register_fonts(blob, None)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, _)| id)
|
||||||
|
.next()?;
|
||||||
|
font_cx.collection.family_name(id).map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Works around `fontique` 0.11.1's Android backend never resolving
|
||||||
|
/// `GenericFamily::Monospace` (confirmed against
|
||||||
|
/// `fontique-0.11.1/src/backend/android.rs`'s `SystemFonts::new`, and still
|
||||||
|
/// present on `linebender/parley`'s `main` as of 2026-09-07, so there is no
|
||||||
|
/// released fix to bump to yet -- see DECISIONS.md's 2026-09-07 entry,
|
||||||
|
/// "Platform fonts," for the full account). Two bugs stack, not one:
|
||||||
|
/// `DEFAULT_GENERIC_FAMILIES` looks up the name `"monospace"` *before*
|
||||||
|
/// `fonts.xml` is parsed into that same name map, and even after parsing,
|
||||||
|
/// AOSP's `fonts.xml` names it with a `<family name="monospace">` element
|
||||||
|
/// (not an `<alias>`) whose `<font>` children the backend's own parser
|
||||||
|
/// does not read (a `TODO` in that match arm) -- so the name gets a
|
||||||
|
/// `FamilyId` with no font data behind it, and `family_by_name("monospace")`
|
||||||
|
/// comes back empty too. Confirmed on this checkout's emulator: `adb pull
|
||||||
|
/// /system/etc/fonts.xml` shows
|
||||||
|
/// `<family name="monospace"><font weight="400"
|
||||||
|
/// style="normal">DroidSansMono.ttf</font></family>` with no matching
|
||||||
|
/// alias.
|
||||||
|
///
|
||||||
|
/// So this reads `fonts.xml` itself (already on-device, already the
|
||||||
|
/// authority Compose's own `Typeface.MONOSPACE` resolves through) for the
|
||||||
|
/// filename that declaration names, then finds which of fontique's
|
||||||
|
/// *actually* scanned families (from `/system/fonts`, which do carry real
|
||||||
|
/// font data, just under whatever name the font's own metadata gives it --
|
||||||
|
/// "Droid Sans Mono" here, but that name is never hardcoded) owns a font
|
||||||
|
/// file with that name, and registers that family as the `Monospace`
|
||||||
|
/// generic the way the backend itself would have if its parser had reified
|
||||||
|
/// the declaration. A no-op if the family is somehow already resolved
|
||||||
|
/// (future fontique) or nothing matches (no `fonts.xml`, e.g. a headless
|
||||||
|
/// test, or a device that names it some other way).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn patch_android_monospace(font_cx: &mut FontContext) {
|
||||||
|
use parley::fontique::SourceKind;
|
||||||
|
|
||||||
|
let already_resolved = font_cx
|
||||||
|
.collection
|
||||||
|
.generic_families(GenericFamily::Monospace)
|
||||||
|
.next()
|
||||||
|
.is_some();
|
||||||
|
if already_resolved {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(target_file) = android_monospace_font_filename() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let names: Vec<String> = font_cx
|
||||||
|
.collection
|
||||||
|
.family_names()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect();
|
||||||
|
for name in names {
|
||||||
|
let Some(id) = font_cx.collection.family_id(&name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(info) = font_cx.collection.family(id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(font) = info.default_font() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let SourceKind::Path(path) = font.source().kind() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if path.file_name().and_then(|f| f.to_str()) == Some(target_file.as_str()) {
|
||||||
|
font_cx
|
||||||
|
.collection
|
||||||
|
.append_generic_families(GenericFamily::Monospace, std::iter::once(id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the font filename `fonts.xml` names for its `"monospace"` family
|
||||||
|
/// (e.g. `"DroidSansMono.ttf"`), by plain substring search rather than a
|
||||||
|
/// real XML parser -- a new dependency for one well-known, stable AOSP file
|
||||||
|
/// whose structure fontique itself already parses with a full parser one
|
||||||
|
/// module over. Not a general XML reader; assumes the file has exactly one
|
||||||
|
/// `<family name="monospace">` element with at least one `<font>` child,
|
||||||
|
/// which is the format on every AOSP `fonts.xml` this was checked against.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn android_monospace_font_filename() -> Option<String> {
|
||||||
|
let android_root = std::env::var("ANDROID_ROOT").unwrap_or_else(|_| "/system".to_string());
|
||||||
|
let xml =
|
||||||
|
std::fs::read_to_string(std::path::Path::new(&android_root).join("etc/fonts.xml")).ok()?;
|
||||||
|
let family_start = xml.find("<family name=\"monospace\">")?;
|
||||||
|
let block = &xml[family_start..];
|
||||||
|
let block = &block[..block.find("</family>")?];
|
||||||
|
let font_tag = block.find("<font")?;
|
||||||
|
let after_tag = &block[font_tag..];
|
||||||
|
let content_start = after_tag.find('>')? + 1;
|
||||||
|
let content = &after_tag[content_start..];
|
||||||
|
let filename = content[..content.find('<')?].trim();
|
||||||
|
(!filename.is_empty()).then(|| filename.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
fn patch_android_monospace(_font_cx: &mut FontContext) {}
|
||||||
|
|
||||||
|
impl TextData {
|
||||||
|
/// [`Family::Icons`] as the name the bundled font actually registered
|
||||||
|
/// under; everything else unchanged.
|
||||||
|
///
|
||||||
|
/// Cloned rather than borrowed because the caller needs it while the
|
||||||
|
/// layout builder holds `&mut self` -- a `String` per shaped icon run,
|
||||||
|
/// paid only when the layout is rebuilt.
|
||||||
|
pub fn resolve_family(&self, family: &Family) -> Family {
|
||||||
|
match family {
|
||||||
|
Family::Icons => self
|
||||||
|
.icon_family
|
||||||
|
.clone()
|
||||||
|
.map_or(Family::Icons, Family::Named),
|
||||||
|
other => other.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the startup report -- see `FontDiagnostics`. Queries the
|
||||||
|
/// collection directly (`fontique::Query`) rather than shaping a real
|
||||||
|
/// string, since all that's needed is which family each axis lands on.
|
||||||
|
pub fn font_diagnostics(&mut self) -> FontDiagnostics {
|
||||||
|
use parley::fontique::{Attributes, FontWidth, QueryStatus};
|
||||||
|
let families_found = self.font_cx.collection.family_names().count();
|
||||||
|
let default_family_id = self
|
||||||
|
.font_cx
|
||||||
|
.collection
|
||||||
|
.generic_families(GenericFamily::SansSerif)
|
||||||
|
.next();
|
||||||
|
let default_family = default_family_id
|
||||||
|
.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string));
|
||||||
|
let default_mono_family_id = self
|
||||||
|
.font_cx
|
||||||
|
.collection
|
||||||
|
.generic_families(GenericFamily::Monospace)
|
||||||
|
.next();
|
||||||
|
let default_mono_family = default_mono_family_id
|
||||||
|
.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string));
|
||||||
|
|
||||||
|
// Resolves the family a (generic family, weight, style) query lands
|
||||||
|
// on, without holding the `Query`'s borrow of `collection` across
|
||||||
|
// the `family_name` lookup that needs it back -- the `FamilyId` is
|
||||||
|
// captured out of the closure first, then looked up once `query`
|
||||||
|
// (and its borrow) has been dropped.
|
||||||
|
let mut resolve_family =
|
||||||
|
|generic: GenericFamily, weight: FontWeight, style: FontStyle| -> Option<String> {
|
||||||
|
let mut family_id = None;
|
||||||
|
{
|
||||||
|
let mut query = self
|
||||||
|
.font_cx
|
||||||
|
.collection
|
||||||
|
.query(&mut self.font_cx.source_cache);
|
||||||
|
query.set_families([generic]);
|
||||||
|
query.set_attributes(Attributes {
|
||||||
|
width: FontWidth::NORMAL,
|
||||||
|
style,
|
||||||
|
weight,
|
||||||
|
});
|
||||||
|
query.matches_with(|font| {
|
||||||
|
family_id = Some(font.family.0);
|
||||||
|
QueryStatus::Stop
|
||||||
|
});
|
||||||
|
}
|
||||||
|
family_id.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string))
|
||||||
|
};
|
||||||
|
|
||||||
|
let regular_resolved = resolve_family(
|
||||||
|
GenericFamily::SansSerif,
|
||||||
|
FontWeight::NORMAL,
|
||||||
|
FontStyle::Normal,
|
||||||
|
);
|
||||||
|
let bold_resolved = resolve_family(
|
||||||
|
GenericFamily::SansSerif,
|
||||||
|
FontWeight::BOLD,
|
||||||
|
FontStyle::Normal,
|
||||||
|
);
|
||||||
|
let italic_resolved = resolve_family(
|
||||||
|
GenericFamily::SansSerif,
|
||||||
|
FontWeight::NORMAL,
|
||||||
|
FontStyle::Italic,
|
||||||
|
);
|
||||||
|
let mono_resolved = resolve_family(
|
||||||
|
GenericFamily::Monospace,
|
||||||
|
FontWeight::NORMAL,
|
||||||
|
FontStyle::Normal,
|
||||||
|
);
|
||||||
|
|
||||||
|
FontDiagnostics {
|
||||||
|
families_found,
|
||||||
|
default_family,
|
||||||
|
default_mono_family,
|
||||||
|
regular_resolved,
|
||||||
|
bold_resolved,
|
||||||
|
italic_resolved,
|
||||||
|
mono_resolved,
|
||||||
|
icon_family: self.icon_family.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which family to ask for. Kept as an owned name rather than parley's
|
||||||
|
/// borrowed `FontFamily<'_>` so that a widget can hold one without a lifetime.
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub enum Family {
|
||||||
|
SansSerif,
|
||||||
|
Serif,
|
||||||
|
Monospace,
|
||||||
|
/// The bundled icon font -- see [`crate::icon`] for what is in it.
|
||||||
|
/// Named as an intention rather than as a font name because only
|
||||||
|
/// [`TextData`] knows what the file registered as; it resolves this
|
||||||
|
/// during shaping ([`TextData::resolve_family`]).
|
||||||
|
Icons,
|
||||||
|
Named(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Family {
|
||||||
|
fn family(&self) -> FontFamily<'_> {
|
||||||
|
let name = match self {
|
||||||
|
Self::SansSerif => FontFamilyName::Generic(GenericFamily::SansSerif),
|
||||||
|
Self::Serif => FontFamilyName::Generic(GenericFamily::Serif),
|
||||||
|
Self::Monospace => FontFamilyName::Generic(GenericFamily::Monospace),
|
||||||
|
// Only reachable if `resolve_family` did not run, which no
|
||||||
|
// shaping path allows -- and sans-serif is the honest answer
|
||||||
|
// for a build whose icon font failed to register: the reader
|
||||||
|
// gets the platform's own tofu rather than a wrong picture.
|
||||||
|
Self::Icons => FontFamilyName::Generic(GenericFamily::SansSerif),
|
||||||
|
Self::Named(name) => FontFamilyName::Named(name.as_str().into()),
|
||||||
|
};
|
||||||
|
FontFamily::Single(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One styled run inside a `TextBuffer`, overriding `TextAttrs`' base style
|
||||||
|
/// over `range` (a byte range into the buffer's text). Every field is
|
||||||
|
/// optional so a span only says what it changes -- e.g. a link span sets
|
||||||
|
/// `color` and `underline` and leaves weight/family at the paragraph's own
|
||||||
|
/// default. This is I5's answer to RUST.md's inline-rich-text ceiling
|
||||||
|
/// (`masonry/src/widgets/text_area.rs`'s `StyleSet` is one style for the
|
||||||
|
/// whole editor, with `// TODO: RichTextInput` beside it): parley's own
|
||||||
|
/// `RangedBuilder::push` already takes a style and a range, so per-span
|
||||||
|
/// bold/italic/monospace/colour/underline only needed plumbing this struct
|
||||||
|
/// through to it and giving each glyph its own colour at draw time (see
|
||||||
|
/// `PlacedGlyph::color` and `TextData::place` below) instead of the one
|
||||||
|
/// `RenderedText::color` every glyph used to share.
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
pub struct SpanStyle {
|
||||||
|
pub range: Range<usize>,
|
||||||
|
pub color: Option<UiColor>,
|
||||||
|
pub family: Option<Family>,
|
||||||
|
/// Overrides `TextAttrs::font_size` for just this range -- what lets a
|
||||||
|
/// heading inside a transcript row's single `TextEdit` be bigger than
|
||||||
|
/// the paragraph text around it, so a whole markdown-folded row (block
|
||||||
|
/// and inline styling both) can stay one selectable text buffer instead
|
||||||
|
/// of one widget per block.
|
||||||
|
pub font_size: Option<f32>,
|
||||||
|
pub bold: bool,
|
||||||
|
pub italic: bool,
|
||||||
|
pub underline: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpanStyle {
|
||||||
|
pub fn new(range: Range<usize>) -> Self {
|
||||||
|
Self {
|
||||||
|
range,
|
||||||
|
color: None,
|
||||||
|
family: None,
|
||||||
|
font_size: None,
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
underline: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn color(mut self, color: UiColor) -> Self {
|
||||||
|
self.color = Some(color);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn family(mut self, family: Family) -> Self {
|
||||||
|
self.family = Some(family);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn font_size(mut self, size: f32) -> Self {
|
||||||
|
self.font_size = Some(size);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn bold(mut self) -> Self {
|
||||||
|
self.bold = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn italic(mut self) -> Self {
|
||||||
|
self.italic = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
pub fn underline(mut self) -> Self {
|
||||||
|
self.underline = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
pub struct TextAttrs {
|
pub struct TextAttrs {
|
||||||
pub color: UiColor,
|
pub color: UiColor,
|
||||||
pub font_size: f32,
|
pub font_size: f32,
|
||||||
pub line_height: f32,
|
pub line_height: f32,
|
||||||
pub family: Family<'static>,
|
pub family: Family,
|
||||||
pub wrap: bool,
|
pub wrap: bool,
|
||||||
/// inner alignment of text region (within where it's drawn)
|
/// inner alignment of text region (within where it's drawn)
|
||||||
pub align: RegionAlign,
|
pub align: RegionAlign,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TextAttrs {
|
pub const LINE_HEIGHT_MULT: f32 = 1.1;
|
||||||
pub fn apply(&self, font_system: &mut FontSystem, buf: &mut Buffer, width: Option<f32>) {
|
|
||||||
buf.set_metrics_and_size(
|
|
||||||
font_system,
|
|
||||||
Metrics::new(self.font_size, self.line_height),
|
|
||||||
width,
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let attrs = Attrs::new().family(self.family);
|
|
||||||
let list = AttrsList::new(&attrs);
|
|
||||||
for line in &mut buf.lines {
|
|
||||||
line.set_attrs_list(list.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type TextBuffer = Buffer;
|
|
||||||
|
|
||||||
impl Default for TextAttrs {
|
impl Default for TextAttrs {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -70,122 +454,324 @@ impl Default for TextAttrs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const LINE_HEIGHT_MULT: f32 = 1.1;
|
/// A string together with its laid-out form.
|
||||||
|
///
|
||||||
|
/// The text and the layout live in one place because parley's `Layout` borrows
|
||||||
|
/// nothing but is only meaningful against the string it was built from: keeping
|
||||||
|
/// them apart is how they get out of step.
|
||||||
|
pub struct TextBuffer {
|
||||||
|
text: String,
|
||||||
|
layout: Layout<UiColor>,
|
||||||
|
spans: Vec<SpanStyle>,
|
||||||
|
/// What the current layout was built for, so `shape` can decline to redo
|
||||||
|
/// work that would come out the same. Spans are not part of this key --
|
||||||
|
/// `set_spans` forces `shaped` to `None` directly, the same way `edit`
|
||||||
|
/// does, since spans change far less often than a naive equality check
|
||||||
|
/// on the whole `Vec` would cost to compute every frame.
|
||||||
|
shaped: Option<(TextAttrs, Option<f32>, f32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextBuffer {
|
||||||
|
pub fn new(text: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
text: text.into(),
|
||||||
|
layout: Layout::new(),
|
||||||
|
spans: Vec::new(),
|
||||||
|
shaped: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace this buffer's per-range style overrides (I5's rich text --
|
||||||
|
/// see `SpanStyle`). Invalidates the layout unconditionally, mirroring
|
||||||
|
/// `set_text`.
|
||||||
|
pub fn set_spans(&mut self, spans: Vec<SpanStyle>) {
|
||||||
|
self.spans = spans;
|
||||||
|
self.shaped = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_empty() -> Self {
|
||||||
|
Self::new("")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn text(&self) -> &str {
|
||||||
|
&self.text
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn layout(&self) -> &Layout<UiColor> {
|
||||||
|
&self.layout
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.text.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_text(&mut self, text: impl Into<String>) {
|
||||||
|
let text = text.into();
|
||||||
|
if text != self.text {
|
||||||
|
self.text = text;
|
||||||
|
self.shaped = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Edit the string in place; invalidates the layout unconditionally, since
|
||||||
|
/// the caller is assumed to have changed something.
|
||||||
|
pub fn edit(&mut self) -> &mut String {
|
||||||
|
self.shaped = None;
|
||||||
|
&mut self.text
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn size(&self) -> Vec2 {
|
||||||
|
Vec2::new(self.layout.width(), self.layout.height())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lay the text out, unless it is already laid out for these
|
||||||
|
/// attributes, this width and this density.
|
||||||
|
///
|
||||||
|
/// **`attrs.font_size`/`line_height` and every span's own `font_size`
|
||||||
|
/// are density-independent (dp) units, multiplied by `density` here --
|
||||||
|
/// the one place text crosses from the widget tree's dp sizes into the
|
||||||
|
/// physical pixels the shaper and rasteriser (`TextData::place`) both
|
||||||
|
/// then work in.** This is what makes glyphs sharp on a dense display:
|
||||||
|
/// before this existed, `font_size` was already a physical-pixel value
|
||||||
|
/// (RUST.md's P0 box's global-scale stopgap resolved density by
|
||||||
|
/// stretching the whole rendered frame afterward instead), so a glyph
|
||||||
|
/// was rasterised small and then upscaled by whatever the display's
|
||||||
|
/// scale factor was -- exactly the blur Iris's report described.
|
||||||
|
/// Multiplying here instead means the font size hitting `ScaleContext`
|
||||||
|
/// in `place` below is already the display's real physical size, so
|
||||||
|
/// the atlas holds a bitmap at the resolution it is actually shown at.
|
||||||
|
/// `GlyphKey.size` already keys on that resolved `font_size`
|
||||||
|
/// (`(font_size * 16.0).round()`), so a cache entry is naturally per
|
||||||
|
/// physical size with no change needed there.
|
||||||
|
pub fn shape(
|
||||||
|
&mut self,
|
||||||
|
data: &mut TextData,
|
||||||
|
attrs: &TextAttrs,
|
||||||
|
width: Option<f32>,
|
||||||
|
density: f32,
|
||||||
|
) {
|
||||||
|
if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Resolved before the builder borrows `data`: `Family::Icons`
|
||||||
|
// names an intention, and the name behind it lives on `TextData`.
|
||||||
|
let base_family = data.resolve_family(&attrs.family);
|
||||||
|
let span_families: Vec<Option<Family>> = self
|
||||||
|
.spans
|
||||||
|
.iter()
|
||||||
|
.map(|span| span.family.as_ref().map(|f| data.resolve_family(f)))
|
||||||
|
.collect();
|
||||||
|
let mut builder = data
|
||||||
|
.layout_cx
|
||||||
|
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
|
||||||
|
builder.push_default(StyleProperty::FontFamily(base_family.family()));
|
||||||
|
builder.push_default(StyleProperty::FontSize(attrs.font_size * density));
|
||||||
|
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
|
||||||
|
attrs.line_height * density,
|
||||||
|
)));
|
||||||
|
builder.push_default(StyleProperty::Brush(attrs.color));
|
||||||
|
for (span, family) in self.spans.iter().zip(&span_families) {
|
||||||
|
let range = span.range.clone();
|
||||||
|
if let Some(color) = span.color {
|
||||||
|
builder.push(StyleProperty::Brush(color), range.clone());
|
||||||
|
}
|
||||||
|
if let Some(family) = family {
|
||||||
|
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
|
||||||
|
}
|
||||||
|
if let Some(size) = span.font_size {
|
||||||
|
builder.push(StyleProperty::FontSize(size * density), range.clone());
|
||||||
|
}
|
||||||
|
if span.bold {
|
||||||
|
builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone());
|
||||||
|
}
|
||||||
|
if span.italic {
|
||||||
|
builder.push(StyleProperty::FontStyle(FontStyle::Italic), range.clone());
|
||||||
|
}
|
||||||
|
if span.underline {
|
||||||
|
builder.push(StyleProperty::Underline(true), range.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.build_into(&mut self.layout, &self.text);
|
||||||
|
self.layout.break_all_lines(width);
|
||||||
|
self.layout
|
||||||
|
.align(Alignment::Start, AlignmentOptions::default());
|
||||||
|
self.shaped = Some((attrs.clone(), width, density));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TextData {
|
impl TextData {
|
||||||
pub fn draw(
|
/// Rasterise whatever of `buffer` is not in the atlas yet, and return where
|
||||||
|
/// each glyph goes relative to the text's top-left.
|
||||||
|
///
|
||||||
|
/// Nothing is uploaded for a glyph already in the atlas, which is the point
|
||||||
|
/// of having one: a resize re-runs this and touches the GPU only if the new
|
||||||
|
/// width brought genuinely new glyphs into view.
|
||||||
|
pub fn place(&mut self, buffer: &TextBuffer, textures: &mut Textures) -> Vec<PlacedGlyph> {
|
||||||
|
let mut placed = Vec::new();
|
||||||
|
for line in buffer.layout.lines() {
|
||||||
|
for item in line.items() {
|
||||||
|
let PositionedLayoutItem::GlyphRun(run) = item else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let font = run.run().font();
|
||||||
|
let font_size = run.run().font_size();
|
||||||
|
let coords = run.run().normalized_coords();
|
||||||
|
let run_color = run.style().brush;
|
||||||
|
let Some(font_ref) = FontRef::from_index(font.data.as_ref(), font.index as usize)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let coords_hash = hash_coords(coords);
|
||||||
|
// `font.data.id()` rather than the pointer, so the same font
|
||||||
|
// loaded twice is still one set of entries.
|
||||||
|
let font_id = font.data.id();
|
||||||
|
|
||||||
|
for glyph in run.positioned_glyphs() {
|
||||||
|
let subpixel = ((glyph.x.fract() * 4.0).round() as i32).rem_euclid(4) as u8;
|
||||||
|
let key = GlyphKey {
|
||||||
|
font: font_id,
|
||||||
|
glyph: glyph.id,
|
||||||
|
size: (font_size * 16.0).round() as u32,
|
||||||
|
subpixel,
|
||||||
|
coords: coords_hash,
|
||||||
|
};
|
||||||
|
let entry = match self.atlas.get(&key) {
|
||||||
|
Some(entry) => entry,
|
||||||
|
None => {
|
||||||
|
let mut scaler = self
|
||||||
|
.scale_cx
|
||||||
|
.builder(font_ref)
|
||||||
|
.size(font_size)
|
||||||
|
.hint(true)
|
||||||
|
.normalized_coords(coords)
|
||||||
|
.build();
|
||||||
|
let image = Render::new(&[
|
||||||
|
Source::ColorOutline(0),
|
||||||
|
Source::ColorBitmap(StrikeWith::BestFit),
|
||||||
|
Source::Outline,
|
||||||
|
])
|
||||||
|
.format(Format::Alpha)
|
||||||
|
.offset(Vector::new(subpixel as f32 / 4.0, 0.0))
|
||||||
|
.render(&mut scaler, glyph.id as u16);
|
||||||
|
match image {
|
||||||
|
Some(image) => self.atlas.insert(key, &image, textures),
|
||||||
|
None => {
|
||||||
|
self.atlas.insert_empty(key);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(entry) = entry else { continue };
|
||||||
|
placed.push(PlacedGlyph {
|
||||||
|
entry,
|
||||||
|
offset: Vec2::new(
|
||||||
|
glyph.x.floor() + entry.left as f32,
|
||||||
|
glyph.y.floor() - entry.top as f32,
|
||||||
|
),
|
||||||
|
color: run_color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_coords(coords: &[i16]) -> u64 {
|
||||||
|
// FxHash over the coordinates; they are short and change rarely.
|
||||||
|
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||||
|
for c in coords {
|
||||||
|
h ^= *c as u16 as u64;
|
||||||
|
h = h.wrapping_mul(0x1000_0000_01b3);
|
||||||
|
}
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A laid-out string, ready to draw: where each glyph goes and how big the
|
||||||
|
/// whole thing is.
|
||||||
|
///
|
||||||
|
/// Cheap to clone and to keep, which is the point -- a widget holds one across
|
||||||
|
/// frames and re-emits its quads without going near the rasteriser. `color`
|
||||||
|
/// is the buffer's *base* colour (`TextAttrs::color`) for a caller that wants
|
||||||
|
/// it as a whole (e.g. tinting a cursor to match); the colour each glyph is
|
||||||
|
/// actually drawn in is `PlacedGlyph::color`, which a `SpanStyle` can
|
||||||
|
/// override per range.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RenderedText {
|
||||||
|
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
|
||||||
|
pub size: Vec2,
|
||||||
|
pub color: UiColor,
|
||||||
|
/// The [`GlyphAtlas::generation`] the glyphs above were placed against.
|
||||||
|
/// A holder must re-render rather than re-emit these quads once the
|
||||||
|
/// atlas has moved on (`GlyphAtlas::clear`'s doc says what happens
|
||||||
|
/// otherwise); `Painter::glyphs` debug-asserts it.
|
||||||
|
pub generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextData {
|
||||||
|
/// Lay out and place in one step, which is what a widget wants.
|
||||||
|
pub fn render(
|
||||||
&mut self,
|
&mut self,
|
||||||
buffer: &mut TextBuffer,
|
buffer: &mut TextBuffer,
|
||||||
attrs: &TextAttrs,
|
attrs: &TextAttrs,
|
||||||
|
width: Option<f32>,
|
||||||
textures: &mut Textures,
|
textures: &mut Textures,
|
||||||
|
density: f32,
|
||||||
) -> RenderedText {
|
) -> RenderedText {
|
||||||
// TODO: either this or the layout stuff (or both) is super slow,
|
buffer.shape(self, attrs, width, density);
|
||||||
// should probably do texture packing and things if possible.
|
let glyphs = self.place(buffer, textures);
|
||||||
// very visible if you add just a couple of wrapping texts and resize window
|
|
||||||
// should also be timed to figure out exactly what points need to be sped up
|
|
||||||
// let mut pixels = HashMap::<_, [u8; 4]>::default();
|
|
||||||
let mut min_x = 0;
|
|
||||||
let mut min_y = 0;
|
|
||||||
let mut max_x = 0;
|
|
||||||
let mut max_y = 0;
|
|
||||||
let text_color = {
|
|
||||||
let c = attrs.color;
|
|
||||||
cosmic_text::Color::rgba(c.r, c.g, c.b, c.a)
|
|
||||||
};
|
|
||||||
let mut max_width = 0.0f32;
|
|
||||||
let mut height = 0.0;
|
|
||||||
|
|
||||||
for run in buffer.layout_runs() {
|
|
||||||
for glyph in run.glyphs.iter() {
|
|
||||||
let physical_glyph = glyph.physical((0., 0.), 1.0);
|
|
||||||
|
|
||||||
let glyph_color = match glyph.color_opt {
|
|
||||||
Some(some) => some,
|
|
||||||
None => text_color,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(img) = self
|
|
||||||
.swash_cache
|
|
||||||
.get_image(&mut self.font_system, physical_glyph.cache_key)
|
|
||||||
{
|
|
||||||
let mut pos = img.placement;
|
|
||||||
pos.left += physical_glyph.x;
|
|
||||||
pos.top = physical_glyph.y + run.line_y as i32 - pos.top;
|
|
||||||
min_x = min_x.min(pos.left);
|
|
||||||
min_y = min_y.min(pos.top);
|
|
||||||
max_x = max_x.max(pos.left + pos.width as i32);
|
|
||||||
max_y = max_y.max(pos.top + pos.height as i32);
|
|
||||||
self.glyph_cache
|
|
||||||
.push((pos, physical_glyph.cache_key, glyph_color));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
max_width = max_width.max(run.line_w);
|
|
||||||
height += run.line_height;
|
|
||||||
}
|
|
||||||
let img_width = (max_x - min_x + 1) as u32;
|
|
||||||
let img_height = (max_y - min_y + 1) as u32;
|
|
||||||
let mut image = RgbaImage::new(img_width, img_height);
|
|
||||||
|
|
||||||
for (pos, key, color) in self.glyph_cache.drain(..) {
|
|
||||||
let img = self
|
|
||||||
.swash_cache
|
|
||||||
.get_image(&mut self.font_system, key)
|
|
||||||
.as_ref()
|
|
||||||
.unwrap();
|
|
||||||
let mut merge = |i, color: [u8; 4]| {
|
|
||||||
let i = i as i32;
|
|
||||||
let x = (i % pos.width as i32 + pos.left - min_x) as u32;
|
|
||||||
let y = (i / pos.width as i32 + pos.top - min_y) as u32;
|
|
||||||
let pixel = &mut image[(x, y)].0;
|
|
||||||
// TODO: no clue if proper alpha blending should be done
|
|
||||||
*pixel = Simd::from(color).saturating_add(Simd::from(*pixel)).into();
|
|
||||||
};
|
|
||||||
|
|
||||||
match img.content {
|
|
||||||
SwashContent::Mask => {
|
|
||||||
for (i, a) in img.data.iter().enumerate() {
|
|
||||||
let mut color = color.as_rgba();
|
|
||||||
color[3] = ((color[3] as u32 * *a as u32) / u8::MAX as u32) as u8;
|
|
||||||
merge(i, color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SwashContent::SubpixelMask => todo!("subpixel mask text rendering"),
|
|
||||||
SwashContent::Color => {
|
|
||||||
let (colors, _) = img.data.as_chunks::<4>();
|
|
||||||
for (i, color) in colors.iter().enumerate() {
|
|
||||||
merge(i, *color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let max_dim = 8192;
|
|
||||||
if image.width() > max_dim || image.height() > max_dim {
|
|
||||||
let width = image.width().min(max_dim);
|
|
||||||
let height = image.height().min(max_dim);
|
|
||||||
eprintln!(
|
|
||||||
"WARNING: image of size {:?} cropped to {:?} (texture too big)",
|
|
||||||
image.dimensions(),
|
|
||||||
(width, height)
|
|
||||||
);
|
|
||||||
image = image.view(0, 0, width, height).to_image();
|
|
||||||
}
|
|
||||||
|
|
||||||
RenderedText {
|
RenderedText {
|
||||||
handle: textures.add(image),
|
glyphs: std::sync::Arc::new(glyphs),
|
||||||
top_left_offset: Vec2::new(min_x as f32, min_y as f32),
|
size: buffer.size(),
|
||||||
size: Vec2::new(max_width, height),
|
color: attrs.color,
|
||||||
|
generation: self.atlas.generation(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[cfg(test)]
|
||||||
pub struct RenderedText {
|
mod tests {
|
||||||
pub handle: TextureHandle,
|
use super::*;
|
||||||
pub top_left_offset: Vec2,
|
use crate::icon;
|
||||||
pub size: Vec2,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait HasTextures {
|
/// Every codepoint `icon` names is actually in the subset the script
|
||||||
fn add_texture(&mut self, image: DynamicImage) -> TextureHandle;
|
/// built. This is the failure `build-icon-font.sh`'s own comment warns
|
||||||
|
/// about -- a constant added on one side and not the other is a glyph
|
||||||
|
/// that silently isn't there -- and it is invisible at runtime,
|
||||||
|
/// because a missing glyph draws as nothing rather than as an error.
|
||||||
|
#[test]
|
||||||
|
fn every_icon_is_in_the_bundled_font() {
|
||||||
|
let font = FontRef::from_index(NERD_ICONS, 0).expect("the bundled icon font parses");
|
||||||
|
let charmap = font.charmap();
|
||||||
|
for (name, glyph) in [
|
||||||
|
("OPEN", icon::OPEN),
|
||||||
|
("CLOSED", icon::CLOSED),
|
||||||
|
("COLLAPSE", icon::COLLAPSE),
|
||||||
|
] {
|
||||||
|
let mut chars = glyph.chars();
|
||||||
|
let ch = chars.next().expect("an icon is one character");
|
||||||
|
assert!(chars.next().is_none(), "{name} is more than one character");
|
||||||
|
assert_ne!(
|
||||||
|
charmap.map(ch),
|
||||||
|
0,
|
||||||
|
"{name} (U+{:04X}) is not in nerd_icons.ttf -- add it to \
|
||||||
|
build-icon-font.sh's GLYPHS and rerun the script",
|
||||||
|
ch as u32
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
let family = data.resolve_family(&Family::Icons);
|
||||||
|
assert!(
|
||||||
|
matches!(family, Family::Named(_)),
|
||||||
|
"the bundled icon font did not register: {:?}",
|
||||||
|
data.icon_family
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+294
-35
@@ -1,19 +1,44 @@
|
|||||||
use crate::{
|
use crate::util::{RefCounter, Vec2};
|
||||||
render::TexturePrimitive,
|
|
||||||
util::{RefCounter, Vec2},
|
|
||||||
};
|
|
||||||
use image::{DynamicImage, GenericImageView};
|
use image::{DynamicImage, GenericImageView};
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
ops::Index,
|
ops::Index,
|
||||||
sync::mpsc::{Receiver, Sender, channel},
|
sync::mpsc::{Receiver, Sender, channel},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Which of the two things a texture slot holds. See TEXTURES.md's
|
||||||
|
/// "Recommended shape" for why these are drawn so differently: a page is a
|
||||||
|
/// layer of one shared array texture and never gets its own bind group; a
|
||||||
|
/// standalone image is the opposite, one texture and one bind group, never a
|
||||||
|
/// layer.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TextureKind {
|
||||||
|
Image,
|
||||||
|
/// The array-texture layer this page was assigned. Chosen synchronously
|
||||||
|
/// by `Textures::add_page` rather than by the renderer, because glyph
|
||||||
|
/// insertion needs it in the same call, before any GPU sync happens.
|
||||||
|
Page {
|
||||||
|
layer: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a [`Textures::shared`] texture is a picture of -- exactly, not by
|
||||||
|
/// hash: `owner` names the widget kind whose description it is, and `id`
|
||||||
|
/// packs that description's own fields, so two owners cannot collide and
|
||||||
|
/// a debugger shows which picture a slot holds.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct SharedTextureKey {
|
||||||
|
pub owner: &'static str,
|
||||||
|
pub id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct TextureHandle {
|
pub struct TextureHandle {
|
||||||
inner: TexturePrimitive,
|
slot: u32,
|
||||||
|
kind: TextureKind,
|
||||||
size: Vec2,
|
size: Vec2,
|
||||||
counter: RefCounter,
|
counter: RefCounter,
|
||||||
send: Sender<u32>,
|
send: Sender<(TextureKind, u32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// a texture manager for a ui
|
/// a texture manager for a ui
|
||||||
@@ -21,22 +46,47 @@ pub struct TextureHandle {
|
|||||||
pub struct Textures {
|
pub struct Textures {
|
||||||
free: Vec<u32>,
|
free: Vec<u32>,
|
||||||
images: Vec<Option<DynamicImage>>,
|
images: Vec<Option<DynamicImage>>,
|
||||||
|
/// What each slot is, kept beside the image so a slot can be pushed
|
||||||
|
/// again without the handle that knows -- see [`Textures::reupload`].
|
||||||
|
kinds: Vec<TextureKind>,
|
||||||
|
/// Textures built from a description rather than from a file, one per
|
||||||
|
/// distinct description: see [`Textures::shared`]. The map holds a
|
||||||
|
/// reference of its own, so a shared texture outlives every widget
|
||||||
|
/// drawing it and its slot is never recycled underneath one.
|
||||||
|
shared: HashMap<SharedTextureKey, TextureHandle>,
|
||||||
|
/// Next layer to hand out to an atlas page. Pages are never freed (no
|
||||||
|
/// atlas eviction), so this only grows and `free` never holds one.
|
||||||
|
next_page_layer: u32,
|
||||||
updates: Vec<Update>,
|
updates: Vec<Update>,
|
||||||
send: Sender<u32>,
|
send: Sender<(TextureKind, u32)>,
|
||||||
recv: Receiver<u32>,
|
recv: Receiver<(TextureKind, u32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum TextureUpdate<'a> {
|
pub enum TextureUpdate<'a> {
|
||||||
Push(&'a DynamicImage),
|
Push(TextureKind, &'a DynamicImage),
|
||||||
Set(u32, &'a DynamicImage),
|
Set(TextureKind, u32, &'a DynamicImage),
|
||||||
|
/// Overwrite a rectangle of an existing texture, rather than replacing it.
|
||||||
|
/// The glyph atlas grows a glyph at a time, and re-uploading a whole atlas
|
||||||
|
/// per glyph is megabytes of copy for a few hundred bytes of change.
|
||||||
|
/// Only ever issued against a page -- a standalone image is never patched.
|
||||||
|
Patch(u32, PatchRect, &'a DynamicImage),
|
||||||
Free(u32),
|
Free(u32),
|
||||||
PushFree,
|
PushFree(TextureKind),
|
||||||
SetFree,
|
SetFree,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct PatchRect {
|
||||||
|
pub x: u32,
|
||||||
|
pub y: u32,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
enum Update {
|
enum Update {
|
||||||
Push(u32),
|
Push(TextureKind, u32),
|
||||||
Set(u32),
|
Set(TextureKind, u32),
|
||||||
|
Patch(u32, PatchRect),
|
||||||
Free(u32),
|
Free(u32),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,58 +96,162 @@ impl Textures {
|
|||||||
Self {
|
Self {
|
||||||
free: Vec::new(),
|
free: Vec::new(),
|
||||||
images: Vec::new(),
|
images: Vec::new(),
|
||||||
|
kinds: Vec::new(),
|
||||||
|
shared: HashMap::new(),
|
||||||
|
next_page_layer: 0,
|
||||||
updates: Vec::new(),
|
updates: Vec::new(),
|
||||||
send,
|
send,
|
||||||
recv,
|
recv,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add(&mut self, image: impl Into<DynamicImage>) -> TextureHandle {
|
pub fn add(&mut self, image: impl Into<DynamicImage>) -> TextureHandle {
|
||||||
let image = image.into();
|
let image = image.into();
|
||||||
let size = image.dimensions().into();
|
let size = image.dimensions().into();
|
||||||
let view_idx = self.push(image);
|
let kind = TextureKind::Image;
|
||||||
// 0 == default in renderer; TODO: actually create samplers here
|
let slot = self.push(kind, image);
|
||||||
let sampler_idx = 0;
|
|
||||||
TextureHandle {
|
TextureHandle {
|
||||||
inner: TexturePrimitive {
|
slot,
|
||||||
view_idx,
|
kind,
|
||||||
sampler_idx,
|
|
||||||
},
|
|
||||||
size,
|
size,
|
||||||
counter: RefCounter::new(),
|
counter: RefCounter::new(),
|
||||||
send: self.send.clone(),
|
send: self.send.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push(&mut self, image: DynamicImage) -> u32 {
|
/// Adds a page of the shared glyph atlas array. Only `atlas.rs` should
|
||||||
|
/// call this -- everything else wants `add`.
|
||||||
|
pub fn add_page(&mut self, image: impl Into<DynamicImage>) -> TextureHandle {
|
||||||
|
let image = image.into();
|
||||||
|
let size = image.dimensions().into();
|
||||||
|
let layer = self.next_page_layer;
|
||||||
|
self.next_page_layer += 1;
|
||||||
|
let kind = TextureKind::Page { layer };
|
||||||
|
let slot = self.push(kind, image);
|
||||||
|
TextureHandle {
|
||||||
|
slot,
|
||||||
|
kind,
|
||||||
|
size,
|
||||||
|
counter: RefCounter::new(),
|
||||||
|
send: self.send.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, kind: TextureKind, image: DynamicImage) -> u32 {
|
||||||
if let Some(i) = self.free.pop() {
|
if let Some(i) = self.free.pop() {
|
||||||
self.images[i as usize] = Some(image);
|
self.images[i as usize] = Some(image);
|
||||||
self.updates.push(Update::Set(i));
|
self.kinds[i as usize] = kind;
|
||||||
|
self.updates.push(Update::Set(kind, i));
|
||||||
i
|
i
|
||||||
} else {
|
} else {
|
||||||
let i = self.images.len() as u32;
|
let i = self.images.len() as u32;
|
||||||
self.images.push(Some(image));
|
self.images.push(Some(image));
|
||||||
self.updates.push(Update::Push(i));
|
self.kinds.push(kind);
|
||||||
|
self.updates.push(Update::Push(kind, i));
|
||||||
i
|
i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The one texture for `key`, building it on the first ask and handing
|
||||||
|
/// out a further reference to it every time after.
|
||||||
|
///
|
||||||
|
/// **Why this exists**: a texture rasterised from a *description* --
|
||||||
|
/// `widget::mark`'s triangle, from a direction and a colour -- has as
|
||||||
|
/// many copies as there are widgets asking for it, and each copy is
|
||||||
|
/// its own GPU texture, its own bind group and its own draw call. A
|
||||||
|
/// transcript screen with a folded card per tool call built one per
|
||||||
|
/// card: hundreds of 48x48 textures of three distinct pictures,
|
||||||
|
/// created and freed again as rows recycled. `make` is not called when
|
||||||
|
/// the key is already known, so the rasterising is paid once too.
|
||||||
|
///
|
||||||
|
/// The map keeps its own reference for the life of the `Textures`, so
|
||||||
|
/// a shared slot is never freed and never reused for something else --
|
||||||
|
/// which is what makes a handle held by a long-lived widget safe.
|
||||||
|
pub fn shared(
|
||||||
|
&mut self,
|
||||||
|
key: SharedTextureKey,
|
||||||
|
make: impl FnOnce() -> DynamicImage,
|
||||||
|
) -> TextureHandle {
|
||||||
|
if let Some(handle) = self.shared.get(&key) {
|
||||||
|
return handle.clone();
|
||||||
|
}
|
||||||
|
let handle = self.add(make());
|
||||||
|
self.shared.insert(key, handle.clone());
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored image for a handle, to be written into before `patch`.
|
||||||
|
pub fn image_mut(&mut self, handle: &TextureHandle) -> &mut DynamicImage {
|
||||||
|
self.images[handle.slot as usize]
|
||||||
|
.as_mut()
|
||||||
|
.expect("texture was freed while still held")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue an upload of just `rect`, after writing it with `image_mut`.
|
||||||
|
pub fn patch(&mut self, handle: &TextureHandle, rect: PatchRect) {
|
||||||
|
self.updates.push(Update::Patch(handle.slot, rect));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue every live slot for upload again, in slot order -- what a
|
||||||
|
/// genuinely new GPU device needs, in place of forgetting everything.
|
||||||
|
///
|
||||||
|
/// A new device starts with no textures, and the renderer-side mirror
|
||||||
|
/// of these slots (`render::texture::GpuTextures`) starts empty with
|
||||||
|
/// it. What it must not do is start empty while the handles widgets
|
||||||
|
/// are still holding name slots by *index*: `Textures::reset` used to
|
||||||
|
/// throw this bookkeeping away, which left every live `TextureHandle`
|
||||||
|
/// -- one per `widget::mark`, hundreds on a transcript screen --
|
||||||
|
/// pointing at a slot nothing recognised, and the first frame after an
|
||||||
|
/// Android surface rebuild panicked in `image_bind_group` ("texture
|
||||||
|
/// slot 89 is not a live standalone image: None"). Re-uploading
|
||||||
|
/// instead keeps every index meaning what it meant, because this side
|
||||||
|
/// still holds the images: the slot list is rebuilt identically,
|
||||||
|
/// including the empty slots, which go across as `PushFree` so the
|
||||||
|
/// ones after them still land where they were.
|
||||||
|
///
|
||||||
|
/// The glyph atlas comes back with it and is deliberately *not*
|
||||||
|
/// cleared any more: its pages are slots here, this side holds their
|
||||||
|
/// pixels, and re-uploading them restores exactly the atlas that was
|
||||||
|
/// there -- so an app switch no longer costs a re-rasterisation of
|
||||||
|
/// every glyph on screen either.
|
||||||
|
///
|
||||||
|
/// Pending updates are dropped rather than kept: each is either a push
|
||||||
|
/// or a patch of a slot this replays in full.
|
||||||
|
pub fn reupload(&mut self) {
|
||||||
|
self.updates.clear();
|
||||||
|
self.updates
|
||||||
|
.extend((0..self.images.len() as u32).map(|i| Update::Push(self.kinds[i as usize], i)));
|
||||||
|
}
|
||||||
|
|
||||||
pub fn free(&mut self) {
|
pub fn free(&mut self) {
|
||||||
for idx in self.recv.try_iter() {
|
for (kind, idx) in self.recv.try_iter() {
|
||||||
self.images[idx as usize] = None;
|
self.images[idx as usize] = None;
|
||||||
self.updates.push(Update::Free(idx));
|
self.updates.push(Update::Free(idx));
|
||||||
self.free.push(idx);
|
// A page's slot is never reclaimed: `GlyphAtlas` never drops the
|
||||||
|
// handles it holds, and there is no eviction path for a hole in
|
||||||
|
// the middle of the array's layers. If that ever changes, this
|
||||||
|
// is where a freed page's layer would need to go on a free list
|
||||||
|
// of its own, separate from `free`, which only ever holds
|
||||||
|
// ordinary image slots today.
|
||||||
|
if kind == TextureKind::Image {
|
||||||
|
self.free.push(idx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn updates(&mut self) -> impl Iterator<Item = TextureUpdate<'_>> {
|
pub fn updates(&mut self) -> impl Iterator<Item = TextureUpdate<'_>> {
|
||||||
self.updates.drain(..).map(|u| match u {
|
self.updates.drain(..).map(|u| match u {
|
||||||
Update::Push(i) => self.images[i as usize]
|
Update::Push(kind, i) => self.images[i as usize]
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(TextureUpdate::Push)
|
.map(|img| TextureUpdate::Push(kind, img))
|
||||||
.unwrap_or(TextureUpdate::PushFree),
|
.unwrap_or(TextureUpdate::PushFree(kind)),
|
||||||
Update::Set(i) => self.images[i as usize]
|
Update::Set(kind, i) => self.images[i as usize]
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|img| TextureUpdate::Set(i, img))
|
.map(|img| TextureUpdate::Set(kind, i, img))
|
||||||
|
.unwrap_or(TextureUpdate::SetFree),
|
||||||
|
Update::Patch(i, rect) => self.images[i as usize]
|
||||||
|
.as_ref()
|
||||||
|
.map(|img| TextureUpdate::Patch(i, rect, img))
|
||||||
.unwrap_or(TextureUpdate::SetFree),
|
.unwrap_or(TextureUpdate::SetFree),
|
||||||
Update::Free(i) => TextureUpdate::Free(i),
|
Update::Free(i) => TextureUpdate::Free(i),
|
||||||
})
|
})
|
||||||
@@ -105,18 +259,36 @@ impl Textures {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TextureHandle {
|
impl TextureHandle {
|
||||||
pub fn primitive(&self) -> TexturePrimitive {
|
|
||||||
self.inner
|
|
||||||
}
|
|
||||||
pub fn size(&self) -> Vec2 {
|
pub fn size(&self) -> Vec2 {
|
||||||
self.size
|
self.size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bind-group index this handle draws with. Only valid for a
|
||||||
|
/// standalone image; an atlas page has no bind group of its own -- it
|
||||||
|
/// samples the shared array via `layer()` instead. Getting this wrong is
|
||||||
|
/// a caller bug (the wrong kind of handle reached the wrong draw path),
|
||||||
|
/// not a recoverable condition, so it panics rather than drawing garbage.
|
||||||
|
pub fn image_index(&self) -> u32 {
|
||||||
|
match self.kind {
|
||||||
|
TextureKind::Image => self.slot,
|
||||||
|
TextureKind::Page { .. } => panic!("image_index() called on an atlas page handle"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The layer this page occupies in the shared atlas array texture.
|
||||||
|
/// Only valid for a page handle; see `image_index`'s note.
|
||||||
|
pub fn layer(&self) -> u32 {
|
||||||
|
match self.kind {
|
||||||
|
TextureKind::Page { layer } => layer,
|
||||||
|
TextureKind::Image => panic!("layer() called on a standalone image handle"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for TextureHandle {
|
impl Drop for TextureHandle {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if self.counter.drop() {
|
if self.counter.drop() {
|
||||||
let _ = self.send.send(self.inner.view_idx);
|
let _ = self.send.send((self.kind, self.slot));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,7 +297,7 @@ impl Index<&TextureHandle> for Textures {
|
|||||||
type Output = DynamicImage;
|
type Output = DynamicImage;
|
||||||
|
|
||||||
fn index(&self, index: &TextureHandle) -> &Self::Output {
|
fn index(&self, index: &TextureHandle) -> &Self::Output {
|
||||||
self.images[index.inner.view_idx as usize].as_ref().unwrap()
|
self.images[index.slot as usize].as_ref().unwrap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,3 +306,90 @@ impl Default for Textures {
|
|||||||
Self::new()
|
Self::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use image::RgbaImage;
|
||||||
|
|
||||||
|
fn image(n: u32) -> DynamicImage {
|
||||||
|
RgbaImage::new(n, n).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(id: u64) -> SharedTextureKey {
|
||||||
|
SharedTextureKey { owner: "test", id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `widget::mark` needs: one texture per description, however
|
||||||
|
/// many widgets ask for it, and a different description is a
|
||||||
|
/// different texture.
|
||||||
|
#[test]
|
||||||
|
fn a_shared_texture_is_built_once_and_handed_out_again() {
|
||||||
|
let mut textures = Textures::new();
|
||||||
|
let built = std::cell::Cell::new(0);
|
||||||
|
let make = |textures: &mut Textures, id: u64| {
|
||||||
|
textures.shared(key(id), || {
|
||||||
|
built.set(built.get() + 1);
|
||||||
|
image(4)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let first = make(&mut textures, 1);
|
||||||
|
let again = make(&mut textures, 1);
|
||||||
|
let other = make(&mut textures, 2);
|
||||||
|
assert_eq!(built.get(), 2, "the second ask for key 1 rasterised again");
|
||||||
|
assert_eq!(first.image_index(), again.image_index());
|
||||||
|
assert_ne!(first.image_index(), other.image_index());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The map's own reference is what keeps a shared slot alive: every
|
||||||
|
/// widget holding one can go away and the slot must not be recycled,
|
||||||
|
/// because the next widget to ask gets that same index back.
|
||||||
|
#[test]
|
||||||
|
fn a_shared_slot_is_not_freed_when_the_last_widget_drops_it() {
|
||||||
|
let mut textures = Textures::new();
|
||||||
|
let slot = textures.shared(key(1), || image(4)).image_index();
|
||||||
|
textures.free();
|
||||||
|
let plain = textures.add(image(4));
|
||||||
|
assert_ne!(
|
||||||
|
plain.image_index(),
|
||||||
|
slot,
|
||||||
|
"an ordinary texture was handed the shared mark's slot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new GPU device gets the same slot numbering back, so a handle a
|
||||||
|
/// widget has been holding all along still names its own texture --
|
||||||
|
/// the crash `reupload` replaced `reset` to fix.
|
||||||
|
#[test]
|
||||||
|
fn reupload_replays_every_slot_in_order_including_the_empty_ones() {
|
||||||
|
let mut textures = Textures::new();
|
||||||
|
let keep_a = textures.add(image(4));
|
||||||
|
let dropped = textures.add(image(4));
|
||||||
|
let keep_b = textures.add(image(4));
|
||||||
|
let (a, gone, b) = (
|
||||||
|
keep_a.image_index(),
|
||||||
|
dropped.image_index(),
|
||||||
|
keep_b.image_index(),
|
||||||
|
);
|
||||||
|
drop(dropped);
|
||||||
|
textures.free();
|
||||||
|
// Drain the updates so far, the way a frame does.
|
||||||
|
assert!(textures.updates().count() > 0);
|
||||||
|
|
||||||
|
textures.reupload();
|
||||||
|
let kinds: Vec<String> = textures
|
||||||
|
.updates()
|
||||||
|
.map(|u| match u {
|
||||||
|
TextureUpdate::Push(..) => "push".to_string(),
|
||||||
|
TextureUpdate::PushFree(..) => "push-free".to_string(),
|
||||||
|
_ => "other".to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
kinds,
|
||||||
|
["push", "push-free", "push"],
|
||||||
|
"slots {a}, {gone} (freed) and {b} must replay in order, so the \
|
||||||
|
indices after a hole still land where they were"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
//! A glyph atlas: one texture holding many rasterised glyphs, so drawing text
|
||||||
|
//! is a quad per glyph rather than a texture per string.
|
||||||
|
//!
|
||||||
|
//! What this replaces is why it exists. Text used to be rasterised into its own
|
||||||
|
//! `RgbaImage` and uploaded as a whole texture, per text widget, every time
|
||||||
|
//! anything about it changed -- so every window resize re-rasterised and
|
||||||
|
//! re-uploaded every visible string, which is what the TODO meant by "resizing
|
||||||
|
//! (per frame) is really slow". Here a glyph is rasterised once for a given
|
||||||
|
//! font, size and subpixel offset and then reused by every string that contains
|
||||||
|
//! it, and a resize re-emits quads without touching the GPU's copy at all.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
PatchRect, TextureHandle, Textures, UiColor,
|
||||||
|
util::{HashMap, Vec2},
|
||||||
|
};
|
||||||
|
use image::RgbaImage;
|
||||||
|
use swash::scale::image::{Content, Image};
|
||||||
|
|
||||||
|
/// Side of one atlas page, in pixels. 1024 is 4 MB at RGBA8 -- enough for a
|
||||||
|
/// few thousand glyphs at UI sizes, and small enough that a page nobody fills
|
||||||
|
/// is not a big waste. Also the fixed width/height of every layer of the
|
||||||
|
/// shared array texture in `render::texture` -- `pub(crate)` so that module
|
||||||
|
/// can size it without a second constant to keep in sync.
|
||||||
|
pub(crate) const PAGE: u32 = 1024;
|
||||||
|
|
||||||
|
/// Transparent margin kept around every glyph, so that sampling one cannot
|
||||||
|
/// pick up its neighbour along a shared edge.
|
||||||
|
const PAD: u32 = 1;
|
||||||
|
|
||||||
|
/// Identifies a rasterised glyph. Anything that changes the pixels has to be in
|
||||||
|
/// here, or two different glyphs share one entry and the wrong one is drawn.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct GlyphKey {
|
||||||
|
pub font: u64,
|
||||||
|
pub glyph: u32,
|
||||||
|
/// Font size in 1/16 px, so sizes that round to the same pixels share a
|
||||||
|
/// raster instead of filling the atlas with near-duplicates.
|
||||||
|
pub size: u32,
|
||||||
|
/// Horizontal subpixel phase, in 1/4 px.
|
||||||
|
pub subpixel: u8,
|
||||||
|
/// Hash of the variation coordinates; a variable font at two weights is two
|
||||||
|
/// different sets of pixels from one glyph id.
|
||||||
|
pub coords: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct GlyphEntry {
|
||||||
|
pub uv_min: [f32; 2],
|
||||||
|
pub uv_max: [f32; 2],
|
||||||
|
/// Offset from the glyph's pen position to the top-left of its pixels.
|
||||||
|
pub left: i32,
|
||||||
|
pub top: i32,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub is_color: bool,
|
||||||
|
/// The atlas array layer this glyph's page occupies.
|
||||||
|
pub layer: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Page {
|
||||||
|
handle: TextureHandle,
|
||||||
|
/// Shelf packing: glyphs are placed left to right along a shelf whose
|
||||||
|
/// height is the tallest glyph on it, and a new shelf starts above when the
|
||||||
|
/// row runs out. Chosen over a real packer because glyphs at one size are
|
||||||
|
/// close to the same height, which is the case shelves are good at.
|
||||||
|
x: u32,
|
||||||
|
y: u32,
|
||||||
|
shelf_height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct GlyphAtlas {
|
||||||
|
pages: Vec<Page>,
|
||||||
|
/// Bumped by [`GlyphAtlas::clear`], so anything holding placed glyphs
|
||||||
|
/// from an earlier atlas can tell that its coordinates are stale --
|
||||||
|
/// see that method's doc for what goes wrong without it.
|
||||||
|
generation: u64,
|
||||||
|
/// `None` for a glyph that rasterised to nothing -- a space, say. Cached
|
||||||
|
/// too, so it is not re-rasterised on every layout.
|
||||||
|
entries: HashMap<GlyphKey, Option<GlyphEntry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GlyphAtlas {
|
||||||
|
pub fn get(&self, key: &GlyphKey) -> Option<Option<GlyphEntry>> {
|
||||||
|
self.entries.get(key).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rasterised pixels in, a place in the atlas out. `None` means the glyph
|
||||||
|
/// has no pixels, which is a normal answer rather than a failure.
|
||||||
|
pub fn insert(
|
||||||
|
&mut self,
|
||||||
|
key: GlyphKey,
|
||||||
|
image: &Image,
|
||||||
|
textures: &mut Textures,
|
||||||
|
) -> Option<GlyphEntry> {
|
||||||
|
let w = image.placement.width;
|
||||||
|
let h = image.placement.height;
|
||||||
|
if w == 0 || h == 0 {
|
||||||
|
self.entries.insert(key, None);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if w + PAD * 2 > PAGE || h + PAD * 2 > PAGE {
|
||||||
|
// A single glyph larger than a page. Refusing is better than
|
||||||
|
// silently drawing a cropped one; the caller draws nothing.
|
||||||
|
self.entries.insert(key, None);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (page_idx, x, y) = self.allocate(w, h, textures);
|
||||||
|
let page = &self.pages[page_idx];
|
||||||
|
|
||||||
|
let img = textures.image_mut(&page.handle);
|
||||||
|
let rgba = img.as_mut_rgba8().expect("atlas page is rgba8");
|
||||||
|
write_glyph(rgba, image, x, y);
|
||||||
|
|
||||||
|
let handle = page.handle.clone();
|
||||||
|
let rect = PatchRect {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
};
|
||||||
|
textures.patch(&handle, rect);
|
||||||
|
|
||||||
|
let page = &self.pages[page_idx];
|
||||||
|
let scale = 1.0 / PAGE as f32;
|
||||||
|
let entry = GlyphEntry {
|
||||||
|
uv_min: [x as f32 * scale, y as f32 * scale],
|
||||||
|
uv_max: [(x + w) as f32 * scale, (y + h) as f32 * scale],
|
||||||
|
left: image.placement.left,
|
||||||
|
top: image.placement.top,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
is_color: matches!(image.content, Content::Color),
|
||||||
|
layer: page.handle.layer(),
|
||||||
|
};
|
||||||
|
self.entries.insert(key, Some(entry));
|
||||||
|
Some(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A free `w`x`h` spot, opening a shelf or a page as needed.
|
||||||
|
fn allocate(&mut self, w: u32, h: u32, textures: &mut Textures) -> (usize, u32, u32) {
|
||||||
|
let need_w = w + PAD;
|
||||||
|
let need_h = h + PAD;
|
||||||
|
if let Some(i) = self.pages.iter().position(|p| fits(p, need_w, need_h)) {
|
||||||
|
let page = &mut self.pages[i];
|
||||||
|
if page.x + need_w > PAGE {
|
||||||
|
page.y += page.shelf_height;
|
||||||
|
page.x = PAD;
|
||||||
|
page.shelf_height = 0;
|
||||||
|
}
|
||||||
|
let (x, y) = (page.x, page.y);
|
||||||
|
page.x += need_w;
|
||||||
|
page.shelf_height = page.shelf_height.max(need_h);
|
||||||
|
return (i, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = textures.add_page(RgbaImage::new(PAGE, PAGE));
|
||||||
|
self.pages.push(Page {
|
||||||
|
handle,
|
||||||
|
x: PAD + w + PAD,
|
||||||
|
y: PAD,
|
||||||
|
shelf_height: h + PAD,
|
||||||
|
});
|
||||||
|
(self.pages.len() - 1, PAD, PAD)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that a glyph has no pixels, so it is not re-rasterised.
|
||||||
|
pub fn insert_empty(&mut self, key: GlyphKey) {
|
||||||
|
self.entries.insert(key, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which atlas the entries handed out right now belong to. A
|
||||||
|
/// [`crate::RenderedText`] records this when it is built and is only
|
||||||
|
/// reusable while it still matches.
|
||||||
|
pub fn generation(&self) -> u64 {
|
||||||
|
self.generation
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn page_count(&self) -> usize {
|
||||||
|
self.pages.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn glyph_count(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget every page and every rasterised entry -- what a genuinely new
|
||||||
|
/// GPU device needs (`android::view::IrisViewPeer::surface_changed`'s
|
||||||
|
/// "not already live" branch, e.g. after backgrounding): the pages this
|
||||||
|
/// atlas remembers are `TextureHandle`s into the *old* device's
|
||||||
|
/// textures, which no longer exist, and every `GlyphEntry`'s `uv_min`/
|
||||||
|
/// `uv_max`/`layer` point into them. Without this, a glyph already
|
||||||
|
/// cached here is treated as "already placed" and never re-inserted
|
||||||
|
/// into the fresh (empty) atlas the new renderer actually has --
|
||||||
|
/// exactly the "rectangles stay, glyphs disappear" bug the resize path
|
||||||
|
/// (`AndroidRenderer::resize`) was built to avoid for the reuse case;
|
||||||
|
/// this is its counterpart for the case where the renderer really is
|
||||||
|
/// new. Dropping `pages` also drops its `TextureHandle`s, which send a
|
||||||
|
/// free message back through their `Textures`; see `Textures::reset`'s
|
||||||
|
/// doc for why that is harmless here.
|
||||||
|
/// Bumping `generation` here is the other half of the same
|
||||||
|
/// invalidation: emptying this atlas does nothing about the
|
||||||
|
/// `RenderedText`s widgets are *already holding*
|
||||||
|
/// (`iris::widget::TextView`'s `tex` cache), whose `PlacedGlyph`s carry
|
||||||
|
/// `uv_min`/`uv_max`/`layer` into the atlas that has just been thrown
|
||||||
|
/// away. Those redraw perfectly happily and sample whatever now sits at
|
||||||
|
/// those coordinates -- the fragments-of-other-glyphs Iris photographed
|
||||||
|
/// after resuming the app on 2026-09-06. One counter, checked where the
|
||||||
|
/// cache is read, is what makes a cached render un-reusable across a
|
||||||
|
/// renderer rebuild.
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.pages.clear();
|
||||||
|
self.entries.clear();
|
||||||
|
self.generation += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fits(page: &Page, need_w: u32, need_h: u32) -> bool {
|
||||||
|
// On the current shelf, or on a new one above it.
|
||||||
|
(page.x + need_w <= PAGE && page.y + need_h <= PAGE)
|
||||||
|
|| (need_w + PAD <= PAGE && page.y + page.shelf_height + need_h <= PAGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy one rasterised glyph into the page image at `(x, y)`.
|
||||||
|
///
|
||||||
|
/// A mask glyph keeps its coverage in alpha with the colour left to the shader,
|
||||||
|
/// so one raster serves text of any colour; a colour glyph carries its own.
|
||||||
|
fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) {
|
||||||
|
let w = image.placement.width;
|
||||||
|
let h = image.placement.height;
|
||||||
|
match image.content {
|
||||||
|
Content::Mask => {
|
||||||
|
for row in 0..h {
|
||||||
|
for col in 0..w {
|
||||||
|
let a = image.data[(row * w + col) as usize];
|
||||||
|
page.put_pixel(x + col, y + row, image::Rgba([255, 255, 255, a]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Content::Color => {
|
||||||
|
for row in 0..h {
|
||||||
|
for col in 0..w {
|
||||||
|
let i = ((row * w + col) * 4) as usize;
|
||||||
|
let px = [
|
||||||
|
image.data[i],
|
||||||
|
image.data[i + 1],
|
||||||
|
image.data[i + 2],
|
||||||
|
image.data[i + 3],
|
||||||
|
];
|
||||||
|
page.put_pixel(x + col, y + row, image::Rgba(px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Content::SubpixelMask => {
|
||||||
|
// Not asked for: `Format::Alpha` is what the renderer requests, so
|
||||||
|
// reaching here means the request changed and this needs writing.
|
||||||
|
// Drawn as a plain mask from the green channel rather than dropped,
|
||||||
|
// so the text is readable rather than absent.
|
||||||
|
for row in 0..h {
|
||||||
|
for col in 0..w {
|
||||||
|
let i = ((row * w + col) * 4) as usize;
|
||||||
|
let a = image.data[i + 1];
|
||||||
|
page.put_pixel(x + col, y + row, image::Rgba([255, 255, 255, a]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a glyph goes on screen, in pixels relative to the text's origin.
|
||||||
|
///
|
||||||
|
/// `color` is per-glyph (read from the parley run's own `Brush`, since
|
||||||
|
/// `UiColor` is parley's brush type here) rather than a single colour for
|
||||||
|
/// the whole `RenderedText`, so that a span pushed with its own
|
||||||
|
/// `StyleProperty::Brush` (I5's inline rich text: a link, a diff of colour
|
||||||
|
/// inside one wrapped paragraph) actually renders in that colour instead of
|
||||||
|
/// the buffer's base one.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct PlacedGlyph {
|
||||||
|
pub entry: GlyphEntry,
|
||||||
|
pub offset: Vec2,
|
||||||
|
pub color: UiColor,
|
||||||
|
}
|
||||||
+100
-18
@@ -8,6 +8,15 @@ pub struct WindowUniform {
|
|||||||
pub height: f32,
|
pub height: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One primitive's placement and what to draw there, in the one arena
|
||||||
|
/// every layer shares (`Primitives`). Read from a storage buffer by
|
||||||
|
/// **both** shader stages: the vertex stage for the corners of the
|
||||||
|
/// primitive it is drawing, the fragment stage for the corners of a
|
||||||
|
/// *mask's* primitive, which is generally a different one and often in
|
||||||
|
/// another layer. A layer's vertex buffer carries only the slot
|
||||||
|
/// ([`instance_slot_layout`]), so there is exactly one copy of a
|
||||||
|
/// placement and a mask cannot disagree with what was drawn. See
|
||||||
|
/// LAYOUT.md's "Masks with a shape".
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
pub struct PrimitiveInstance {
|
pub struct PrimitiveInstance {
|
||||||
@@ -15,25 +24,20 @@ pub struct PrimitiveInstance {
|
|||||||
pub binding: u32,
|
pub binding: u32,
|
||||||
pub idx: u32,
|
pub idx: u32,
|
||||||
pub mask_idx: MaskIdx,
|
pub mask_idx: MaskIdx,
|
||||||
|
pub move_idx: MoveIdx,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PrimitiveInstance {
|
/// The vertex layout of a layer's draw order: one `u32` slot into the
|
||||||
const ATTRIBS: [VertexAttribute; 7] = vertex_attr_array![
|
/// global instance arena per instance, stepped per instance. Everything a
|
||||||
0 => Float32x2,
|
/// primitive is made of used to be here as eight vertex attributes; it
|
||||||
1 => Float32x2,
|
/// moved into the storage buffer above so the fragment stage can read it
|
||||||
2 => Float32x2,
|
/// too.
|
||||||
3 => Float32x2,
|
pub fn instance_slot_layout() -> VertexBufferLayout<'static> {
|
||||||
4 => Uint32,
|
const ATTRIBS: [VertexAttribute; 1] = vertex_attr_array![0 => Uint32];
|
||||||
5 => Uint32,
|
VertexBufferLayout {
|
||||||
6 => Uint32,
|
array_stride: std::mem::size_of::<u32>() as BufferAddress,
|
||||||
];
|
step_mode: VertexStepMode::Instance,
|
||||||
|
attributes: &ATTRIBS,
|
||||||
pub fn desc() -> VertexBufferLayout<'static> {
|
|
||||||
VertexBufferLayout {
|
|
||||||
array_stride: std::mem::size_of::<Self>() as BufferAddress,
|
|
||||||
step_mode: VertexStepMode::Instance,
|
|
||||||
attributes: &Self::ATTRIBS,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,8 +47,86 @@ impl MaskIdx {
|
|||||||
pub const NONE: Self = Self::preset(u32::MAX);
|
pub const NONE: Self = Self::preset(u32::MAX);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub type MoveIdx = Id<u32>;
|
||||||
|
|
||||||
|
/// A clip, as a reference to a primitive already written plus the mask it
|
||||||
|
/// nests inside. The fragment stage evaluates that primitive's coverage
|
||||||
|
/// *at the masked pixel* -- for a rect, the same `rounded_rect_coverage`
|
||||||
|
/// from the same SDF the rect itself is drawn with -- and multiplies it
|
||||||
|
/// into the pixel's alpha, so a rounded container's corner and its
|
||||||
|
/// children's clipped corner are the same arithmetic and cannot disagree.
|
||||||
|
/// See LAYOUT.md's "Masks with a shape".
|
||||||
|
///
|
||||||
|
/// **No `kind` and no `flags`**, which the design sketched: the referenced
|
||||||
|
/// instance already carries its own `binding`, and a copy of it here is a
|
||||||
|
/// second thing to keep in step; alpha-only is the only mode there is, so
|
||||||
|
/// there is nothing to select. Both are a field away if a second mode
|
||||||
|
/// appears.
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
pub struct Mask {
|
pub struct Mask {
|
||||||
pub region: UiRegion,
|
/// The slot in `UiRenderState::primitives` of the primitive whose
|
||||||
|
/// coverage this mask is. Today always a `RectPrimitive`: a glyph or
|
||||||
|
/// a standalone image would need, respectively, a CPU-side alpha
|
||||||
|
/// plane for the hit test to agree with the shader, and a bind-group
|
||||||
|
/// switch the fragment stage cannot make -- `Painter::set_mask`
|
||||||
|
/// rejects both by name rather than leaving the shader to read a rect
|
||||||
|
/// that is not there.
|
||||||
|
///
|
||||||
|
/// Who owns it depends on which way the mask was set. A plain
|
||||||
|
/// `.masked()` writes its own undrawn rect, so the primitive is in
|
||||||
|
/// the masking widget's `ActiveData::primitives` and lives exactly as
|
||||||
|
/// long as the mask. `.masked_by(shape)` points at a *child's*
|
||||||
|
/// primitive, which that child can free on any redraw of its own --
|
||||||
|
/// so `UiRenderState::remask_shape_users` marks the mask's owner for
|
||||||
|
/// redraw whenever a referenced slot is freed, since that widget's
|
||||||
|
/// own `set_mask` is the only thing that resolves the slot again.
|
||||||
|
pub primitive: u32,
|
||||||
|
/// The mask this one was set *inside* (`MaskIdx::NONE` at the top), so
|
||||||
|
/// clipping nests: the fragment stage walks the chain and multiplies
|
||||||
|
/// every coverage on it, which is what makes a pixel inside two
|
||||||
|
/// feathered corners dimmed by both. Chained rather than intersected
|
||||||
|
/// on the CPU because each mask moves with its own widget -- a code
|
||||||
|
/// fence inside a transcript row carries the row's scroll, the list's
|
||||||
|
/// own box does not, and one region resolved when the fence was last
|
||||||
|
/// drawn gets the second of those wrong as soon as the row moves.
|
||||||
|
///
|
||||||
|
/// A child holds one ref on its parent's slot (`Painter::set_mask`),
|
||||||
|
/// released when the child's own slot goes
|
||||||
|
/// (`UiRenderState::remove`), so the chain cannot outlive what it
|
||||||
|
/// points at.
|
||||||
|
pub parent: MaskIdx,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One widget's cumulative on-screen translation, and the slot of the
|
||||||
|
/// ancestor to add on top of it. `parent == u32::MAX` ends the chain. A
|
||||||
|
/// pure abs-pixel delta, not a general `UiRegion` remap -- sufficient for
|
||||||
|
/// every call site that moves a widget (`ScrollArea`, `Offset`) since both are
|
||||||
|
/// translations of an already-drawn subtree. See LAYOUT.md section 2.
|
||||||
|
///
|
||||||
|
/// `_pad` matches WGSL's storage-buffer layout for `MoveOffset`: `delta` is
|
||||||
|
/// a `vec2<f32>`, which gives the struct an 8-byte alignment and rounds its
|
||||||
|
/// WGSL size up to 16 bytes even though `delta` + `parent` only total 12 --
|
||||||
|
/// the same trap `GlyphPrimitive` documents below. `bytemuck` does not
|
||||||
|
/// check this for us, and getting it wrong is a wgpu validation panic at
|
||||||
|
/// draw time ("buffer bound ... with size 12 where the shader expects 16"),
|
||||||
|
/// not a compile error.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
|
pub struct MoveOffset {
|
||||||
|
pub delta: [f32; 2],
|
||||||
|
pub parent: u32,
|
||||||
|
_pad: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MoveOffset {
|
||||||
|
pub const NONE_PARENT: u32 = u32::MAX;
|
||||||
|
|
||||||
|
pub fn new(delta: [f32; 2], parent: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
delta,
|
||||||
|
parent,
|
||||||
|
_pad: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// The frame budget `dumpsys gfxinfo` also uses to call a frame "janky": the
|
||||||
|
/// 60Hz vsync period. Kept as the same threshold so a percentage from this
|
||||||
|
/// report and a percentage from `gfxinfo` mean the same thing. Only a
|
||||||
|
/// fallback now that a caller can read the display's real refresh rate
|
||||||
|
/// (`report_at_hz`/`mark_phase`'s callers) -- most devices are 60Hz, but a
|
||||||
|
/// 90Hz or 120Hz phone judged against this constant would call every frame
|
||||||
|
/// "late" that merely met its own, faster budget.
|
||||||
|
pub const JANK_THRESHOLD: Duration = Duration::from_nanos(16_666_667);
|
||||||
|
|
||||||
|
/// Enough frames for several minutes of scrolling before the oldest ones
|
||||||
|
/// start being overwritten -- the same "diagnostic, not a log" sizing
|
||||||
|
/// `FrameStats.kt`'s `CAP` uses on the Compose side, chosen independently
|
||||||
|
/// here since a `Duration` is smaller than the six `Long` arrays it keeps.
|
||||||
|
/// Bumped from 4096 for RUST.md's "Benchmark v2": a fling+stream+type+
|
||||||
|
/// keyboard run is ~6,500+ frames on the Compose side, comfortably under
|
||||||
|
/// this so `phase_stats` never has to report a phase as partially evicted.
|
||||||
|
const RING_CAPACITY: usize = 16384;
|
||||||
|
|
||||||
|
/// One `mark_phase` call: the wall-clock instant and the (0-based,
|
||||||
|
/// never-reset-by-`reset`-except-at-`reset`-time) absolute frame index at
|
||||||
|
/// which a phase began -- `phase_stats` slices `index_ring` against this to
|
||||||
|
/// find which recorded samples belong to which phase, since the ring
|
||||||
|
/// itself only keeps the most recent `RING_CAPACITY` samples' *values*,
|
||||||
|
/// not which phase they were in.
|
||||||
|
struct PhaseMark {
|
||||||
|
name: String,
|
||||||
|
start_index: u64,
|
||||||
|
start_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One phase's own slice of a report -- RUST.md's "Benchmark v2" spec's
|
||||||
|
/// "per-phase blocks in `FrameReport`... frames, late count/percent...
|
||||||
|
/// p50/p90/p99, worst, duration". `Display` matches the shape
|
||||||
|
/// `docs/bench/compose-phone-v2-2026-09-06.md`'s report already uses, so
|
||||||
|
/// the two apps' reports read the same way side by side.
|
||||||
|
pub struct PhaseStats {
|
||||||
|
pub name: String,
|
||||||
|
/// How many frames were recorded during this phase in total -- may
|
||||||
|
/// exceed `late + (samples counted)` if some of this phase's frames
|
||||||
|
/// have since been evicted from the ring by a very long run; that
|
||||||
|
/// case is named in the `Display` rather than silently under-counted.
|
||||||
|
pub frames: u64,
|
||||||
|
pub duration: Duration,
|
||||||
|
pub late: u64,
|
||||||
|
pub late_percent: f64,
|
||||||
|
pub p50: Duration,
|
||||||
|
pub p90: Duration,
|
||||||
|
pub p99: Duration,
|
||||||
|
pub worst: Duration,
|
||||||
|
/// `false` if this phase's frame count exceeds how many samples of it
|
||||||
|
/// are still in the ring -- the percentiles above are then computed
|
||||||
|
/// over whatever survived, not the whole phase. UI_RULES.md: this is
|
||||||
|
/// the "we don't fully know" state, named rather than folded silently
|
||||||
|
/// into a number that looks exact.
|
||||||
|
pub complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for PhaseStats {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
writeln!(
|
||||||
|
f,
|
||||||
|
" {}: {} frames over {:.1}s{}",
|
||||||
|
self.name,
|
||||||
|
self.frames,
|
||||||
|
self.duration.as_secs_f64(),
|
||||||
|
if self.complete {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
" (ring evicted some of this phase)"
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
writeln!(f, " late: {} ({:.1}%)", self.late, self.late_percent)?;
|
||||||
|
writeln!(
|
||||||
|
f,
|
||||||
|
" total p50 {:.1}ms p90 {:.1}ms p99 {:.1}ms",
|
||||||
|
self.p50.as_secs_f64() * 1000.0,
|
||||||
|
self.p90.as_secs_f64() * 1000.0,
|
||||||
|
self.p99.as_secs_f64() * 1000.0,
|
||||||
|
)?;
|
||||||
|
write!(f, " worst {:.1}ms", self.worst.as_secs_f64() * 1000.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A per-frame wall-time report iris keeps of itself, because `dumpsys
|
||||||
|
/// gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all
|
||||||
|
/// (RUST.md's I5 box, "Measurements taken" (b)): it instruments Android's
|
||||||
|
/// ordinary Skia/HWUI View-drawing pipeline, which a `wgpu`-rendered
|
||||||
|
/// `SurfaceView` bypasses entirely. `record` is meant to be called once per
|
||||||
|
/// frame, wrapping the same span Compose's own render report and `gfxinfo`
|
||||||
|
/// count -- from the frame's redraw/update start to after the frame is
|
||||||
|
/// handed to the platform to present.
|
||||||
|
///
|
||||||
|
/// **What this does not measure**: wgpu's `present()` call queues the frame
|
||||||
|
/// with the compositor and returns; it is not fenced against the GPU
|
||||||
|
/// actually finishing the frame or the compositor actually showing it, the
|
||||||
|
/// way `gfxinfo`'s own `GPU_DURATION`/vsync accounting is. So a sample here
|
||||||
|
/// is "how long the CPU took to build and submit this frame", not
|
||||||
|
/// "how long the frame took to reach the screen" -- named in
|
||||||
|
/// [`FrameStats`]'s own `Display` line rather than presented as the latter,
|
||||||
|
/// per the standing rule against showing an inferred number as a measured
|
||||||
|
/// one where the two differ.
|
||||||
|
///
|
||||||
|
/// Fixed-size ring, no allocation on the hot path -- `report()` is the only
|
||||||
|
/// place that allocates (a sort over the current ring), and it is only
|
||||||
|
/// ever called from a button tap, not once per frame.
|
||||||
|
pub struct FrameReport {
|
||||||
|
ring: Box<[Duration; RING_CAPACITY]>,
|
||||||
|
/// The `submit_to_present` half of each sample in `ring`, same index,
|
||||||
|
/// same lifetime -- kept as a second ring rather than a ring of pairs so
|
||||||
|
/// the existing `ring`/percentile code above is untouched (RUST.md's I5
|
||||||
|
/// "Where iris's frame time goes" CPU/GPU split, added 2026-09-05).
|
||||||
|
/// `ring[i] - submit_ring[i]` is that frame's `redraw_to_submit` half.
|
||||||
|
submit_ring: Box<[Duration; RING_CAPACITY]>,
|
||||||
|
/// The absolute (0-based, since the last `reset`) frame index each
|
||||||
|
/// `ring`/`submit_ring` slot's sample belongs to -- what `phase_stats`
|
||||||
|
/// slices against `PhaseMark::start_index` to tell which recorded
|
||||||
|
/// frames fall in which phase.
|
||||||
|
index_ring: Box<[u64; RING_CAPACITY]>,
|
||||||
|
/// How many of `ring`'s slots hold a real sample -- saturates at
|
||||||
|
/// `RING_CAPACITY`, unlike `total_frames` below which keeps counting.
|
||||||
|
len: usize,
|
||||||
|
pos: usize,
|
||||||
|
/// All frames recorded since the last `reset`, even past `RING_CAPACITY`
|
||||||
|
/// -- what `janky_percent` divides by, so a long run's percentage stays
|
||||||
|
/// correct even once the ring itself only holds the most recent frames.
|
||||||
|
total_frames: u64,
|
||||||
|
janky_frames: u64,
|
||||||
|
/// `mark_phase` calls since the last `reset`, oldest first -- see
|
||||||
|
/// `phase_stats`. Empty on an ordinary run that never calls
|
||||||
|
/// `mark_phase`, so `phase_stats` returns an empty `Vec` and a caller
|
||||||
|
/// prints no "per phase:" section at all, matching RUST.md's "empty/
|
||||||
|
/// absent on an ordinary 'Copy' press, which never marks a phase."
|
||||||
|
phases: Vec<PhaseMark>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One resolved reading. `Display` is the log line both the "Frame report"
|
||||||
|
/// button and `transcript-bench.sh`-style scripts read, grep-able on
|
||||||
|
/// `"iris frame report"`.
|
||||||
|
pub struct FrameStats {
|
||||||
|
pub total_frames: u64,
|
||||||
|
pub janky_percent: f64,
|
||||||
|
pub p50: Duration,
|
||||||
|
pub p90: Duration,
|
||||||
|
pub p99: Duration,
|
||||||
|
pub worst: Duration,
|
||||||
|
/// Median of `redraw_to_submit` -- iris's own CPU work (layout, text,
|
||||||
|
/// primitive building) up to and including building the `queue.submit`
|
||||||
|
/// call, per frame. RUST.md's I5 "Where iris's frame time goes" split,
|
||||||
|
/// added 2026-09-05 to answer "CPU or GPU?" with a number rather than a
|
||||||
|
/// guess.
|
||||||
|
pub cpu_p50: Duration,
|
||||||
|
/// Median of `submit_to_present` -- the `queue.submit` call itself plus
|
||||||
|
/// `present()`, i.e. wherever the driver/GPU/compositor wait actually
|
||||||
|
/// happens. Same caveat as the type's own doc: `present()` is not
|
||||||
|
/// fenced against the GPU actually finishing, so this is "how long the
|
||||||
|
/// CPU was blocked handing the frame off", not the frame's true GPU
|
||||||
|
/// time -- still enough to separate "iris is slow building the frame"
|
||||||
|
/// from "iris is slow handing it to the driver".
|
||||||
|
pub gpu_wait_p50: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for FrameStats {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"frames={} janky%={:.2} p50={:.1}ms p90={:.1}ms p99={:.1}ms worst={:.1}ms \
|
||||||
|
(measures redraw-start to after present() is called, not GPU/compositor \
|
||||||
|
completion)",
|
||||||
|
self.total_frames,
|
||||||
|
self.janky_percent,
|
||||||
|
self.p50.as_secs_f64() * 1000.0,
|
||||||
|
self.p90.as_secs_f64() * 1000.0,
|
||||||
|
self.p99.as_secs_f64() * 1000.0,
|
||||||
|
self.worst.as_secs_f64() * 1000.0,
|
||||||
|
)?;
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
" cpu_p50={:.1}ms gpu_wait_p50={:.1}ms (redraw-start-to-submit vs. \
|
||||||
|
submit-to-after-present)",
|
||||||
|
self.cpu_p50.as_secs_f64() * 1000.0,
|
||||||
|
self.gpu_wait_p50.as_secs_f64() * 1000.0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameReport {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
ring: Box::new([Duration::ZERO; RING_CAPACITY]),
|
||||||
|
submit_ring: Box::new([Duration::ZERO; RING_CAPACITY]),
|
||||||
|
index_ring: Box::new([0; RING_CAPACITY]),
|
||||||
|
len: 0,
|
||||||
|
pos: 0,
|
||||||
|
total_frames: 0,
|
||||||
|
janky_frames: 0,
|
||||||
|
phases: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record one frame's elapsed wall time, with no CPU/GPU split (the
|
||||||
|
/// `submit_to_present` half is recorded as zero, so `cpu_p50` reads as
|
||||||
|
/// the whole frame and `gpu_wait_p50` as nothing -- honest for a caller
|
||||||
|
/// that never measured the split, rather than fabricating one). O(1),
|
||||||
|
/// no allocation.
|
||||||
|
pub fn record(&mut self, elapsed: Duration) {
|
||||||
|
self.record_split(elapsed, Duration::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record one frame's elapsed wall time, split at `queue.submit`:
|
||||||
|
/// `submit_to_present` is the `queue.submit()` call plus `present()`;
|
||||||
|
/// `total - submit_to_present` is everything before it (layout, text,
|
||||||
|
/// primitive building). RUST.md's I5 "Where iris's frame time goes"
|
||||||
|
/// CPU/GPU split, added 2026-09-05. O(1), no allocation.
|
||||||
|
pub fn record_split(&mut self, total: Duration, submit_to_present: Duration) {
|
||||||
|
self.ring[self.pos] = total;
|
||||||
|
self.submit_ring[self.pos] = submit_to_present;
|
||||||
|
self.index_ring[self.pos] = self.total_frames;
|
||||||
|
self.pos = (self.pos + 1) % RING_CAPACITY;
|
||||||
|
self.len = (self.len + 1).min(RING_CAPACITY);
|
||||||
|
self.total_frames += 1;
|
||||||
|
if total > JANK_THRESHOLD {
|
||||||
|
self.janky_frames += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears every counter and every sample -- what the "Reset frame
|
||||||
|
/// report" control calls, so a report covers only what was scrolled
|
||||||
|
/// after the button was pressed (the same reason `FrameStats.kt`'s
|
||||||
|
/// `reset()` exists on the Compose side). Also clears every phase
|
||||||
|
/// mark, so a fresh run starts with no "per phase:" section until it
|
||||||
|
/// marks one of its own.
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.len = 0;
|
||||||
|
self.pos = 0;
|
||||||
|
self.total_frames = 0;
|
||||||
|
self.janky_frames = 0;
|
||||||
|
self.phases.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the start of a named phase at the current moment -- every
|
||||||
|
/// frame recorded from here until the next `mark_phase` (or `reset`)
|
||||||
|
/// belongs to it. RUST.md's "Benchmark v2": a scripted bench run calls
|
||||||
|
/// this once per phase (fling/stream/type/keyboard) so `phase_stats`
|
||||||
|
/// can slice one whole run's frames by what was happening during each.
|
||||||
|
pub fn mark_phase(&mut self, name: &str) {
|
||||||
|
// `phase_stats`'s slicing (`idx >= phase.start_index && idx <
|
||||||
|
// end_index`) silently produces an empty or nonsensical slice for
|
||||||
|
// a phase pushed out of order rather than surfacing the misuse
|
||||||
|
// (docs/REVIEW-2026-09-06.md finding 5).
|
||||||
|
debug_assert!(
|
||||||
|
self.phases
|
||||||
|
.last()
|
||||||
|
.is_none_or(|p| self.total_frames >= p.start_index)
|
||||||
|
);
|
||||||
|
self.phases.push(PhaseMark {
|
||||||
|
name: name.to_string(),
|
||||||
|
start_index: self.total_frames,
|
||||||
|
start_at: Instant::now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One [`PhaseStats`] per `mark_phase` call since the last `reset`,
|
||||||
|
/// oldest first. `now` closes the last phase's wall-clock span (there
|
||||||
|
/// is no "next phase" instant to use for it); `refresh_hz` is what
|
||||||
|
/// each phase's own `late`/`late_percent` is judged against, read from
|
||||||
|
/// the display rather than assumed -- RUST.md's "Benchmark v2": "late
|
||||||
|
/// count/% against the display's refresh rate."
|
||||||
|
pub fn phase_stats(&self, now: Instant, refresh_hz: f32) -> Vec<PhaseStats> {
|
||||||
|
if self.phases.is_empty() || refresh_hz <= 0.0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
|
||||||
|
self.phases
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, phase)| {
|
||||||
|
let (end_index, end_at) = match self.phases.get(i + 1) {
|
||||||
|
Some(next) => (next.start_index, next.start_at),
|
||||||
|
None => (self.total_frames, now),
|
||||||
|
};
|
||||||
|
let frames = end_index.saturating_sub(phase.start_index);
|
||||||
|
let mut samples: Vec<Duration> = (0..self.len)
|
||||||
|
.filter(|&j| {
|
||||||
|
let idx = self.index_ring[j];
|
||||||
|
idx >= phase.start_index && idx < end_index
|
||||||
|
})
|
||||||
|
.map(|j| self.ring[j])
|
||||||
|
.collect();
|
||||||
|
let complete = samples.len() as u64 >= frames;
|
||||||
|
if samples.is_empty() {
|
||||||
|
return PhaseStats {
|
||||||
|
name: phase.name.clone(),
|
||||||
|
frames,
|
||||||
|
duration: end_at.saturating_duration_since(phase.start_at),
|
||||||
|
late: 0,
|
||||||
|
late_percent: 0.0,
|
||||||
|
p50: Duration::ZERO,
|
||||||
|
p90: Duration::ZERO,
|
||||||
|
p99: Duration::ZERO,
|
||||||
|
worst: Duration::ZERO,
|
||||||
|
complete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
samples.sort_unstable();
|
||||||
|
let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)];
|
||||||
|
let late = samples.iter().filter(|&&d| d > budget).count() as u64;
|
||||||
|
PhaseStats {
|
||||||
|
name: phase.name.clone(),
|
||||||
|
frames,
|
||||||
|
duration: end_at.saturating_duration_since(phase.start_at),
|
||||||
|
late,
|
||||||
|
late_percent: 100.0 * late as f64 / samples.len() as f64,
|
||||||
|
p50: pct(50),
|
||||||
|
p90: pct(90),
|
||||||
|
p99: pct(99),
|
||||||
|
worst: *samples.last().expect("checked not empty above"),
|
||||||
|
complete,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `None` if nothing has been recorded since the last reset -- the
|
||||||
|
/// "no frames recorded, scroll first" case, not a zeroed report that
|
||||||
|
/// would read as a real (perfect) measurement.
|
||||||
|
pub fn report(&self) -> Option<FrameStats> {
|
||||||
|
if self.len == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut samples: Vec<Duration> = self.ring[..self.len].to_vec();
|
||||||
|
samples.sort_unstable();
|
||||||
|
let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)];
|
||||||
|
|
||||||
|
// Separate arrays rather than subtracting the two medians above:
|
||||||
|
// medians do not distribute over subtraction, and each needs its
|
||||||
|
// own sort.
|
||||||
|
let submit_samples: Vec<Duration> = self.submit_ring[..self.len].to_vec();
|
||||||
|
let cpu_samples: Vec<Duration> = self.ring[..self.len]
|
||||||
|
.iter()
|
||||||
|
.zip(self.submit_ring[..self.len].iter())
|
||||||
|
.map(|(&total, &submit_to_present)| total.saturating_sub(submit_to_present))
|
||||||
|
.collect();
|
||||||
|
let median = |mut v: Vec<Duration>| {
|
||||||
|
v.sort_unstable();
|
||||||
|
v[v.len() / 2]
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(FrameStats {
|
||||||
|
total_frames: self.total_frames,
|
||||||
|
janky_percent: 100.0 * self.janky_frames as f64 / self.total_frames as f64,
|
||||||
|
p50: pct(50),
|
||||||
|
p90: pct(90),
|
||||||
|
p99: pct(99),
|
||||||
|
worst: *samples.last().expect("len > 0 checked above"),
|
||||||
|
cpu_p50: median(cpu_samples),
|
||||||
|
gpu_wait_p50: median(submit_samples),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(late count, late percent)` over every sample still in the ring,
|
||||||
|
/// judged against `refresh_hz`'s own frame budget rather than the
|
||||||
|
/// fixed 60Hz `JANK_THRESHOLD` -- RUST.md's "Benchmark v2": "late
|
||||||
|
/// count/% against the display's refresh rate... print 'at N Hz (X ms
|
||||||
|
/// budget)' like Compose does." A separate method from `report()`
|
||||||
|
/// rather than a parameter on it, so `report()`'s own `janky_percent`
|
||||||
|
/// (and the exact-boundary test pinned to `JANK_THRESHOLD`) is
|
||||||
|
/// unaffected for every existing caller that never measured a real
|
||||||
|
/// refresh rate. `(0, 0.0)` with nothing recorded or a non-positive
|
||||||
|
/// `refresh_hz`.
|
||||||
|
pub fn late_at_hz(&self, refresh_hz: f32) -> (u64, f64) {
|
||||||
|
if self.len == 0 || refresh_hz <= 0.0 {
|
||||||
|
return (0, 0.0);
|
||||||
|
}
|
||||||
|
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
|
||||||
|
let late = self.ring[..self.len]
|
||||||
|
.iter()
|
||||||
|
.filter(|&&d| d > budget)
|
||||||
|
.count() as u64;
|
||||||
|
(late, 100.0 * late as f64 / self.len as f64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FrameReport {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_frames_reports_none() {
|
||||||
|
assert!(FrameReport::new().report().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_frame_is_every_percentile_and_the_worst() {
|
||||||
|
let mut r = FrameReport::new();
|
||||||
|
r.record(Duration::from_millis(10));
|
||||||
|
let stats = r.report().unwrap();
|
||||||
|
assert_eq!(stats.total_frames, 1);
|
||||||
|
assert_eq!(stats.p50, Duration::from_millis(10));
|
||||||
|
assert_eq!(stats.p99, Duration::from_millis(10));
|
||||||
|
assert_eq!(stats.worst, Duration::from_millis(10));
|
||||||
|
assert_eq!(stats.janky_percent, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn percentiles_and_worst_over_a_known_set() {
|
||||||
|
let mut r = FrameReport::new();
|
||||||
|
// 100 samples, 1ms..=100ms, fed out of order so the ring's own
|
||||||
|
// order is not what gives the right answer -- the sort has to.
|
||||||
|
for ms in (1..=100).rev() {
|
||||||
|
r.record(Duration::from_millis(ms));
|
||||||
|
}
|
||||||
|
let stats = r.report().unwrap();
|
||||||
|
assert_eq!(stats.total_frames, 100);
|
||||||
|
assert_eq!(stats.p50, Duration::from_millis(51));
|
||||||
|
assert_eq!(stats.p90, Duration::from_millis(91));
|
||||||
|
assert_eq!(stats.p99, Duration::from_millis(100));
|
||||||
|
assert_eq!(stats.worst, Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jank_threshold_matches_gfxinfos_60hz_budget() {
|
||||||
|
let mut r = FrameReport::new();
|
||||||
|
r.record(Duration::from_nanos(16_666_667)); // exactly on budget: not janky
|
||||||
|
r.record(Duration::from_nanos(16_666_668)); // one ns over: janky
|
||||||
|
let stats = r.report().unwrap();
|
||||||
|
assert_eq!(stats.janky_percent, 50.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn janky_percent_is_over_all_time_frames_not_just_the_ring() {
|
||||||
|
// Fewer than RING_CAPACITY frames, all janky, then a fresh reset --
|
||||||
|
// the percentage must reset to 0, not divide by a stale count.
|
||||||
|
let mut r = FrameReport::new();
|
||||||
|
for _ in 0..10 {
|
||||||
|
r.record(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
assert_eq!(r.report().unwrap().janky_percent, 100.0);
|
||||||
|
r.reset();
|
||||||
|
assert!(r.report().is_none());
|
||||||
|
r.record(Duration::from_millis(1));
|
||||||
|
assert_eq!(r.report().unwrap().janky_percent, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_without_a_split_reports_the_whole_frame_as_cpu() {
|
||||||
|
// A caller that never measured the split (plain `record`) should
|
||||||
|
// not fabricate a GPU-wait number -- it reads as zero, and the CPU
|
||||||
|
// half reads as the whole frame.
|
||||||
|
let mut r = FrameReport::new();
|
||||||
|
r.record(Duration::from_millis(20));
|
||||||
|
let stats = r.report().unwrap();
|
||||||
|
assert_eq!(stats.cpu_p50, Duration::from_millis(20));
|
||||||
|
assert_eq!(stats.gpu_wait_p50, Duration::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_split_reports_each_halfs_own_median() {
|
||||||
|
let mut r = FrameReport::new();
|
||||||
|
// Three frames: total is always 30ms, but the CPU/GPU-wait split
|
||||||
|
// moves, so the two medians must be independent of each other and
|
||||||
|
// of `total`'s own median.
|
||||||
|
r.record_split(Duration::from_millis(30), Duration::from_millis(5));
|
||||||
|
r.record_split(Duration::from_millis(30), Duration::from_millis(10));
|
||||||
|
r.record_split(Duration::from_millis(30), Duration::from_millis(20));
|
||||||
|
let stats = r.report().unwrap();
|
||||||
|
assert_eq!(stats.p50, Duration::from_millis(30));
|
||||||
|
assert_eq!(stats.gpu_wait_p50, Duration::from_millis(10));
|
||||||
|
assert_eq!(stats.cpu_p50, Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ring_wraps_without_growing_past_capacity() {
|
||||||
|
let mut r = FrameReport::new();
|
||||||
|
for i in 0..(RING_CAPACITY * 2) {
|
||||||
|
r.record(Duration::from_millis(1 + (i % 5) as u64));
|
||||||
|
}
|
||||||
|
let stats = r.report().unwrap();
|
||||||
|
// total_frames keeps the full count even once the ring has wrapped.
|
||||||
|
assert_eq!(stats.total_frames, (RING_CAPACITY * 2) as u64);
|
||||||
|
// but every sample the ring can report on is still one of the five
|
||||||
|
// values fed in, since a wrap can only overwrite with more of the
|
||||||
|
// same pattern here.
|
||||||
|
assert!(stats.worst <= Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_marks_means_no_phases() {
|
||||||
|
let mut r = FrameReport::new();
|
||||||
|
r.record(Duration::from_millis(5));
|
||||||
|
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn phases_slice_frames_by_when_they_were_marked() {
|
||||||
|
let mut r = FrameReport::new();
|
||||||
|
r.mark_phase("a");
|
||||||
|
for _ in 0..5 {
|
||||||
|
r.record(Duration::from_millis(10)); // 10ms: late at 60Hz (16.7ms budget)... no, 10<16.7, not late
|
||||||
|
}
|
||||||
|
r.mark_phase("b");
|
||||||
|
for _ in 0..3 {
|
||||||
|
r.record(Duration::from_millis(20)); // 20ms: late at 60Hz
|
||||||
|
}
|
||||||
|
let now = Instant::now();
|
||||||
|
let phases = r.phase_stats(now, 60.0);
|
||||||
|
assert_eq!(phases.len(), 2);
|
||||||
|
assert_eq!(phases[0].name, "a");
|
||||||
|
assert_eq!(phases[0].frames, 5);
|
||||||
|
assert_eq!(phases[0].late, 0);
|
||||||
|
assert_eq!(phases[0].worst, Duration::from_millis(10));
|
||||||
|
assert_eq!(phases[1].name, "b");
|
||||||
|
assert_eq!(phases[1].frames, 3);
|
||||||
|
assert_eq!(phases[1].late, 3);
|
||||||
|
assert_eq!(phases[1].late_percent, 100.0);
|
||||||
|
assert_eq!(phases[1].worst, Duration::from_millis(20));
|
||||||
|
assert!(phases[0].complete);
|
||||||
|
assert!(phases[1].complete);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_last_phase_runs_until_now() {
|
||||||
|
let mut r = FrameReport::new();
|
||||||
|
r.mark_phase("only");
|
||||||
|
r.record(Duration::from_millis(1));
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
let now = Instant::now();
|
||||||
|
let phases = r.phase_stats(now, 60.0);
|
||||||
|
assert_eq!(phases.len(), 1);
|
||||||
|
assert!(phases[0].duration >= Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_phase_marks() {
|
||||||
|
let mut r = FrameReport::new();
|
||||||
|
r.mark_phase("a");
|
||||||
|
r.record(Duration::from_millis(1));
|
||||||
|
r.reset();
|
||||||
|
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn late_at_hz_uses_the_given_refresh_rate_not_the_fixed_60hz_constant() {
|
||||||
|
let mut r = FrameReport::new();
|
||||||
|
// 10ms is under 60Hz's 16.7ms budget but over 120Hz's 8.3ms one.
|
||||||
|
r.record(Duration::from_millis(10));
|
||||||
|
assert_eq!(r.late_at_hz(60.0), (0, 0.0));
|
||||||
|
assert_eq!(r.late_at_hz(120.0), (1, 100.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
+468
-111
@@ -1,30 +1,141 @@
|
|||||||
use std::num::NonZero;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
UiData, UiRenderState,
|
UiData, UiRenderState,
|
||||||
render::{data::PrimitiveInstance, texture::GpuTextures, util::ArrBuf},
|
render::{
|
||||||
util::HashMap,
|
data::{PrimitiveInstance, instance_slot_layout},
|
||||||
|
texture::GpuTextures,
|
||||||
|
util::ArrBuf,
|
||||||
|
},
|
||||||
|
util::{HashMap, Vec2},
|
||||||
};
|
};
|
||||||
use data::WindowUniform;
|
use data::WindowUniform;
|
||||||
|
use pollster::FutureExt;
|
||||||
use wgpu::{
|
use wgpu::{
|
||||||
util::{BufferInitDescriptor, DeviceExt},
|
util::{BufferInitDescriptor, DeviceExt},
|
||||||
*,
|
*,
|
||||||
};
|
};
|
||||||
use winit::dpi::PhysicalSize;
|
|
||||||
|
|
||||||
|
mod atlas;
|
||||||
mod data;
|
mod data;
|
||||||
|
mod frame_report;
|
||||||
mod primitive;
|
mod primitive;
|
||||||
|
mod sdf;
|
||||||
mod texture;
|
mod texture;
|
||||||
mod util;
|
mod util;
|
||||||
|
|
||||||
pub use data::{Mask, MaskIdx};
|
pub use atlas::*;
|
||||||
|
pub use data::{Mask, MaskIdx, MoveIdx, MoveOffset};
|
||||||
|
pub use frame_report::{FrameReport, FrameStats, JANK_THRESHOLD};
|
||||||
pub use primitive::*;
|
pub use primitive::*;
|
||||||
|
pub use sdf::{distance_from_rect, rounded_rect_coverage};
|
||||||
|
|
||||||
const SHAPE_SHADER: &str = include_str!("./shader.wgsl");
|
/// The one shader every primitive is drawn with. Public so a test can run
|
||||||
|
/// a function out of it against the CPU transliteration in [`sdf`] --
|
||||||
|
/// `iris/tests/mask_sdf.rs`, which LAYOUT.md's "Masks with a shape" turns
|
||||||
|
/// on: a masked corner that cannot be tapped and a masked corner that is
|
||||||
|
/// not drawn are only the same corner while the two agree.
|
||||||
|
pub const SHAPE_SHADER: &str = include_str!("./shader.wgsl");
|
||||||
|
|
||||||
|
/// The `wgpu::Limits` both platform backends (`android::render::
|
||||||
|
/// AndroidRenderer::new`, `default::render::UiRenderer::new`) ask
|
||||||
|
/// `Adapter::request_device` for -- shared so the two copies cannot drift,
|
||||||
|
/// per AGENTS.md's "write the logic once."
|
||||||
|
///
|
||||||
|
/// Built from `Limits::default()`, **not** a downlevel variant: the shader
|
||||||
|
/// (`shader.wgsl`) reads four `var<storage>` buffers (rects, glyphs, masks,
|
||||||
|
/// move_offsets) from the vertex stage, and `Limits::downlevel_webgl2_defaults()`
|
||||||
|
/// zeroes `max_storage_buffers_per_shader_stage` along with the compute
|
||||||
|
/// limits below -- switching to it would trade one `request_device` crash
|
||||||
|
/// for a bind-group-layout one on the same downlevel hardware this is meant
|
||||||
|
/// to support. `max_buffer_size` is raised for the growing instance/atlas
|
||||||
|
/// buffers (`ArrBuf`, `GpuTextures`); everything else is `default()`'s
|
||||||
|
/// desktop-tier value, unchanged.
|
||||||
|
///
|
||||||
|
/// The six `max_compute_*` fields are zeroed because nothing in this crate
|
||||||
|
/// creates a `ComputePipeline` or writes a `@compute` shader stage --
|
||||||
|
/// grepped for both across `iris`/`iris-core` before writing this, found
|
||||||
|
/// none. `Limits::default()` requests desktop-tier compute limits
|
||||||
|
/// unconditionally (`max_compute_workgroups_per_dimension: 65535`) even
|
||||||
|
/// though nothing asks a device to actually support compute, which is what
|
||||||
|
/// crashed `request_device` on the Android emulator's software GL path
|
||||||
|
/// (`EMU_GPU=software`, `force-gles`): SwiftShader's GL reports itself as
|
||||||
|
/// OpenGL ES 3.0, which has no compute shaders, so the adapter's real limit
|
||||||
|
/// is 0 and the unconditional request fails outright
|
||||||
|
/// (`RUST.md`'s "Software mode ... crashes for a third, different reason").
|
||||||
|
/// The same would happen on a real GLES-3.0-only Android device. If a
|
||||||
|
/// future change adds a compute pass, request the specific limits it needs
|
||||||
|
/// here rather than reverting to the desktop-tier default for everything.
|
||||||
|
pub fn device_limits() -> Limits {
|
||||||
|
Limits {
|
||||||
|
max_buffer_size: 1 << 30,
|
||||||
|
max_compute_workgroup_storage_size: 0,
|
||||||
|
max_compute_invocations_per_workgroup: 0,
|
||||||
|
max_compute_workgroup_size_x: 0,
|
||||||
|
max_compute_workgroup_size_y: 0,
|
||||||
|
max_compute_workgroup_size_z: 0,
|
||||||
|
max_compute_workgroups_per_dimension: 0,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A capped log of wgpu's *uncaptured* errors -- everything that reaches
|
||||||
|
/// `Device::on_uncaptured_error` rather than one of `UiRenderNode::new`'s
|
||||||
|
/// own error scopes, i.e. every wgpu error raised outside device/pipeline
|
||||||
|
/// creation: a validation failure during an ordinary frame's `update`/
|
||||||
|
/// `draw`, for instance. wgpu's default handler for these is `panic!` with
|
||||||
|
/// no caller able to intervene -- exactly what aborted the P0 bench APK
|
||||||
|
/// once already (this file's `UiRenderNode::new` doc comment) -- so both
|
||||||
|
/// platform backends install a handler here instead of leaving the default
|
||||||
|
/// in place, per RUST.md's P0 box ("every wgpu uncaptured error ... it
|
||||||
|
/// must never panic in release").
|
||||||
|
///
|
||||||
|
/// Cheap to `Clone` (an `Arc` around the real storage) rather than a
|
||||||
|
/// process-wide static, so a caller builds one alongside its `Device`,
|
||||||
|
/// hands one clone to `on_uncaptured_error`'s closure and keeps the other
|
||||||
|
/// for the Diagnostics page to read -- context passed explicitly, per
|
||||||
|
/// AGENTS.md/CODE_RULES.md's "no globals" rather than reached for through a
|
||||||
|
/// `OnceLock`.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct WgpuErrorLog {
|
||||||
|
errors: std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many uncaptured errors the log keeps -- old ones drop off the front
|
||||||
|
/// rather than being trimmed on read, so a build spraying errors every
|
||||||
|
/// frame doesn't grow this without bound.
|
||||||
|
const WGPU_ERROR_LOG_CAP: usize = 20;
|
||||||
|
|
||||||
|
impl Default for WgpuErrorLog {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
errors: std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WgpuErrorLog {
|
||||||
|
pub fn record(&self, error: impl std::fmt::Display) {
|
||||||
|
let mut errors = self.errors.lock().unwrap();
|
||||||
|
if errors.len() >= WGPU_ERROR_LOG_CAP {
|
||||||
|
errors.pop_front();
|
||||||
|
}
|
||||||
|
errors.push_back(error.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A snapshot for the Diagnostics page -- cloned rather than held,
|
||||||
|
/// since the lock must not outlive one call.
|
||||||
|
pub fn snapshot(&self) -> Vec<String> {
|
||||||
|
self.errors.lock().unwrap().iter().cloned().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct UiRenderNode {
|
pub struct UiRenderNode {
|
||||||
uniform_group: BindGroup,
|
uniform_group: BindGroup,
|
||||||
primitive_layout: BindGroupLayout,
|
primitive_layout: BindGroupLayout,
|
||||||
|
/// Group 1: `rects` and `glyphs`. Global and bound once per frame,
|
||||||
|
/// not per layer -- a mask referencing a rect drawn in another layer
|
||||||
|
/// has to be able to read it (see `Primitives`).
|
||||||
|
primitives: PrimitiveBuffers,
|
||||||
|
primitive_group: BindGroup,
|
||||||
rsc_layout: BindGroupLayout,
|
rsc_layout: BindGroupLayout,
|
||||||
rsc_group: BindGroup,
|
rsc_group: BindGroup,
|
||||||
|
|
||||||
@@ -34,28 +145,76 @@ pub struct UiRenderNode {
|
|||||||
active: Vec<usize>,
|
active: Vec<usize>,
|
||||||
window_buffer: Buffer,
|
window_buffer: Buffer,
|
||||||
textures: GpuTextures,
|
textures: GpuTextures,
|
||||||
|
/// Every primitive's placement, read by the vertex stage for the
|
||||||
|
/// primitive being drawn and by the fragment stage for a mask's.
|
||||||
|
instances: ArrBuf<PrimitiveInstance>,
|
||||||
masks: ArrBuf<Mask>,
|
masks: ArrBuf<Mask>,
|
||||||
|
move_offsets: ArrBuf<MoveOffset>,
|
||||||
|
/// Group 3: the masks and move-offsets storage buffers, on their own --
|
||||||
|
/// see IRIS_TODO.md's "Appending one image ... rebuilds every other
|
||||||
|
/// image's bind group". These used to live in group 2 alongside each
|
||||||
|
/// standalone image's own texture view, so an image's bind group named
|
||||||
|
/// the masks/move_offsets buffer directly; the moment either buffer
|
||||||
|
/// resized (which a widget getting its *first* move slot can trigger,
|
||||||
|
/// unrelated to any image), `ArrBuf::update` handed back a new `Buffer`
|
||||||
|
/// identity and every image's bind group -- one per live image -- had
|
||||||
|
/// to be rebuilt to reference it. Pulling both buffers into their own
|
||||||
|
/// group, bound once per frame rather than once per draw call, means a
|
||||||
|
/// buffer resize now rebuilds exactly this one group instead of N.
|
||||||
|
masks_layout: BindGroupLayout,
|
||||||
|
masks_group: BindGroup,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One layer's vertex buffers: the slots it draws, in order. The
|
||||||
|
/// primitives themselves are in `UiRenderNode::instances`.
|
||||||
struct RenderLayer {
|
struct RenderLayer {
|
||||||
instance: ArrBuf<PrimitiveInstance>,
|
order: ArrBuf<u32>,
|
||||||
primitives: PrimitiveBuffers,
|
/// A standalone image's slots, kept apart from `order` because each
|
||||||
primitive_group: BindGroup,
|
/// one draws with its own bind group -- see `UiRenderNode::draw`.
|
||||||
|
images: ArrBuf<u32>,
|
||||||
|
/// The texture slot each entry of `images` draws with, in the same
|
||||||
|
/// order, refreshed alongside it. Not in the vertex buffer itself
|
||||||
|
/// because it names a bind group, not shader data.
|
||||||
|
image_tex_indices: Vec<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UiRenderNode {
|
impl UiRenderNode {
|
||||||
pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) {
|
pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) {
|
||||||
pass.set_pipeline(&self.pipeline);
|
pass.set_pipeline(&self.pipeline);
|
||||||
pass.set_bind_group(0, &self.uniform_group, &[]);
|
pass.set_bind_group(0, &self.uniform_group, &[]);
|
||||||
pass.set_bind_group(2, &self.rsc_group, &[]);
|
// Group 1 is global now, so it is set here rather than per layer.
|
||||||
|
pass.set_bind_group(1, &self.primitive_group, &[]);
|
||||||
|
// Set once, not per layer or per image: masks/move_offsets are read
|
||||||
|
// by every primitive and every standalone image alike, and living
|
||||||
|
// in their own group (rather than folded into group 2 alongside the
|
||||||
|
// per-image texture view) is what keeps an image's own bind group
|
||||||
|
// from naming a buffer that changes size on an unrelated widget's
|
||||||
|
// first draw -- see the comment on `masks_group` below.
|
||||||
|
pass.set_bind_group(3, &self.masks_group, &[]);
|
||||||
for i in &self.active {
|
for i in &self.active {
|
||||||
let layer = &self.layers[i];
|
let layer = &self.layers[i];
|
||||||
if layer.instance.len() == 0 {
|
if layer.order.len() == 0 && layer.images.len() == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
pass.set_bind_group(1, &layer.primitive_group, &[]);
|
if layer.order.len() > 0 {
|
||||||
pass.set_vertex_buffer(0, layer.instance.buffer.slice(..));
|
pass.set_bind_group(2, &self.rsc_group, &[]);
|
||||||
pass.draw(0..4, 0..layer.instance.len() as u32);
|
pass.set_vertex_buffer(0, layer.order.buffer.slice(..));
|
||||||
|
pass.draw(0..4, 0..layer.order.len() as u32);
|
||||||
|
}
|
||||||
|
// Images draw after this layer's rects and glyphs, one draw call
|
||||||
|
// each with its own bind group. That draws every image "on top"
|
||||||
|
// within the layer, which loses nothing that currently exists:
|
||||||
|
// `Primitives::apply_free` frees with `swap_remove`, so a layer's
|
||||||
|
// draw order was already undefined before images had their own
|
||||||
|
// list -- nothing before this relied on interleaving a rect
|
||||||
|
// between two images at a particular position.
|
||||||
|
if layer.images.len() > 0 {
|
||||||
|
pass.set_vertex_buffer(0, layer.images.buffer.slice(..));
|
||||||
|
for (k, &tex_idx) in layer.image_tex_indices.iter().enumerate() {
|
||||||
|
pass.set_bind_group(2, self.textures.image_bind_group(tex_idx), &[]);
|
||||||
|
pass.draw(0..4, k as u32..k as u32 + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,79 +224,156 @@ impl UiRenderNode {
|
|||||||
queue: &Queue,
|
queue: &Queue,
|
||||||
ui: &mut UiData,
|
ui: &mut UiData,
|
||||||
ui_render: &mut UiRenderState,
|
ui_render: &mut UiRenderState,
|
||||||
) {
|
) -> FrameUpdateStats {
|
||||||
self.active.clear();
|
self.active.clear();
|
||||||
for (i, primitives) in ui_render.layers.iter_mut() {
|
for (i, order) in ui_render.layers.iter_mut() {
|
||||||
self.active.push(i);
|
self.active.push(i);
|
||||||
for change in primitives.apply_free() {
|
let rlayer = self.layers.entry(i).or_insert_with(|| RenderLayer {
|
||||||
if let Some(inst) = ui_render.active.get_mut(&change.id) {
|
order: ArrBuf::new(
|
||||||
for h in &mut inst.primitives {
|
|
||||||
if h.layer == i && h.inst_idx == change.old {
|
|
||||||
h.inst_idx = change.new;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let rlayer = self.layers.entry(i).or_insert_with(|| {
|
|
||||||
let primitives = PrimitiveBuffers::new(device);
|
|
||||||
let primitive_group =
|
|
||||||
Self::primitive_group(device, &self.primitive_layout, primitives.buffers());
|
|
||||||
RenderLayer {
|
|
||||||
instance: ArrBuf::new(
|
|
||||||
device,
|
|
||||||
BufferUsages::VERTEX | BufferUsages::COPY_DST,
|
|
||||||
"instance",
|
|
||||||
),
|
|
||||||
primitives,
|
|
||||||
primitive_group,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if primitives.updated {
|
|
||||||
rlayer
|
|
||||||
.instance
|
|
||||||
.update(device, queue, primitives.instances());
|
|
||||||
rlayer.primitives.update(device, queue, primitives.data());
|
|
||||||
rlayer.primitive_group = Self::primitive_group(
|
|
||||||
device,
|
device,
|
||||||
&self.primitive_layout,
|
BufferUsages::VERTEX | BufferUsages::COPY_DST,
|
||||||
rlayer.primitives.buffers(),
|
"layer order",
|
||||||
);
|
),
|
||||||
primitives.updated = false;
|
images: ArrBuf::new(
|
||||||
|
device,
|
||||||
|
BufferUsages::VERTEX | BufferUsages::COPY_DST,
|
||||||
|
"layer image order",
|
||||||
|
),
|
||||||
|
image_tex_indices: Vec::new(),
|
||||||
|
});
|
||||||
|
if order.updated {
|
||||||
|
rlayer.order.update(device, queue, order.order());
|
||||||
|
rlayer.images.update(device, queue, order.images());
|
||||||
|
rlayer.image_tex_indices = order
|
||||||
|
.images()
|
||||||
|
.iter()
|
||||||
|
.map(|&slot| ui_render.primitives.instance(slot).idx)
|
||||||
|
.collect();
|
||||||
|
order.updated = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut changed = false;
|
let instances_resized = if ui_render.primitives.updated {
|
||||||
changed |= self.textures.update(&mut ui.textures);
|
ui_render.primitives.updated = false;
|
||||||
if ui.masks.changed {
|
let resized = self
|
||||||
|
.instances
|
||||||
|
.update(device, queue, ui_render.primitives.instances());
|
||||||
|
self.primitives
|
||||||
|
.update(device, queue, ui_render.primitives.data());
|
||||||
|
self.primitive_group =
|
||||||
|
Self::primitive_group(device, &self.primitive_layout, self.primitives.buffers());
|
||||||
|
resized
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
let masks_resized = if ui.masks.changed {
|
||||||
ui.masks.changed = false;
|
ui.masks.changed = false;
|
||||||
self.masks.update(device, queue, &ui.masks[..]);
|
self.masks.update(device, queue, &ui.masks[..])
|
||||||
changed = true;
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
let moves_resized = if ui.move_offsets.changed {
|
||||||
|
ui.move_offsets.changed = false;
|
||||||
|
self.move_offsets
|
||||||
|
.update(device, queue, &ui.move_offsets[..])
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
if masks_resized || moves_resized || instances_resized {
|
||||||
|
self.masks_group = Self::masks_group(
|
||||||
|
device,
|
||||||
|
&self.masks_layout,
|
||||||
|
&self.masks,
|
||||||
|
&self.move_offsets,
|
||||||
|
&self.instances,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if changed {
|
let rebuild_main = self.textures.update(&mut ui.textures, &self.rsc_layout);
|
||||||
self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures, &self.masks);
|
if rebuild_main {
|
||||||
|
self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures);
|
||||||
|
}
|
||||||
|
FrameUpdateStats {
|
||||||
|
masks_resized,
|
||||||
|
moves_resized,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resize(&mut self, size: &PhysicalSize<u32>, queue: &Queue) {
|
/// Takes a size rather than a window type: this is the only thing the
|
||||||
|
/// core wanted from winit, and depending on a windowing backend for two
|
||||||
|
/// numbers is what put `android-activity` in the core's graph for an
|
||||||
|
/// Android build that is meant to go through android-view instead.
|
||||||
|
pub fn resize(&mut self, size: impl Into<Vec2>, queue: &Queue) {
|
||||||
|
let size = size.into();
|
||||||
let slice = &[WindowUniform {
|
let slice = &[WindowUniform {
|
||||||
width: size.width as f32,
|
width: size.x,
|
||||||
height: size.height as f32,
|
height: size.y,
|
||||||
}];
|
}];
|
||||||
queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice));
|
queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds every bind group layout, the pipeline, and the two storage
|
||||||
|
/// buffers this needs -- fallibly, since this is exactly the call that
|
||||||
|
/// aborted the process on Iris's phone in a release build with no
|
||||||
|
/// message beyond "wgpu error: Validation Error" (RUST.md's P0 box,
|
||||||
|
/// "iris bench crash on the phone, 2026-09-06"). wgpu's own default
|
||||||
|
/// behaviour for an uncaptured error is `panic!` with no caller able to
|
||||||
|
/// intervene, so every `create_bind_group_layout`/`create_render_pipeline`
|
||||||
|
/// call below runs inside three nested error scopes (one per
|
||||||
|
/// `ErrorFilter`) instead: whichever scope catches something, its
|
||||||
|
/// `wgpu::Error`'s `Display` is wgpu-core's own `format_error` output
|
||||||
|
/// (`"Validation Error\n\nCaused by:\n ..."`, the same text the panic
|
||||||
|
/// would have printed before Android's crash reporter truncated it) and
|
||||||
|
/// becomes this function's `Err`. Both callers
|
||||||
|
/// (`android::render::AndroidRenderer::new`, `default::render::
|
||||||
|
/// UiRenderer::new`) already call `Device`-creation with
|
||||||
|
/// `pollster::block_on`, so returning a plain `Result` here rather than
|
||||||
|
/// making this `async fn` keeps that same synchronous shape.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
device: &Device,
|
device: &Device,
|
||||||
queue: &Queue,
|
queue: &Queue,
|
||||||
config: &SurfaceConfiguration,
|
config: &SurfaceConfiguration,
|
||||||
limits: UiLimits,
|
window_size: impl Into<Vec2>,
|
||||||
) -> Self {
|
) -> Result<Self, String> {
|
||||||
|
// Popped in reverse of this order, once every creation call below
|
||||||
|
// has run -- `Device::push_error_scope`'s own contract.
|
||||||
|
let oom_scope = device.push_error_scope(ErrorFilter::OutOfMemory);
|
||||||
|
let validation_scope = device.push_error_scope(ErrorFilter::Validation);
|
||||||
|
let internal_scope = device.push_error_scope(ErrorFilter::Internal);
|
||||||
|
|
||||||
let shader = device.create_shader_module(ShaderModuleDescriptor {
|
let shader = device.create_shader_module(ShaderModuleDescriptor {
|
||||||
label: Some("UI Shape Shader"),
|
label: Some("UI Shape Shader"),
|
||||||
source: ShaderSource::Wgsl(SHAPE_SHADER.into()),
|
source: ShaderSource::Wgsl(SHAPE_SHADER.into()),
|
||||||
});
|
});
|
||||||
|
|
||||||
let window_uniform = WindowUniform::default();
|
// Seeded from the caller's own reported size, not
|
||||||
|
// `WindowUniform::default()` (0, 0): the vertex shader divides by
|
||||||
|
// `window.dim` to reach clip space, so a window this buffer
|
||||||
|
// disagrees with means every primitive's position is NaN/Inf and is
|
||||||
|
// dropped before rasterization -- the clear colour still reaches
|
||||||
|
// the screen (the pass runs regardless) while nothing drawn on top
|
||||||
|
// of it ever does. winit's backend gets away with the old default
|
||||||
|
// because winit fires an initial `WindowEvent::Resized` that calls
|
||||||
|
// `resize()` before the first frame; android-view has no such
|
||||||
|
// automatic event, so `AndroidRenderer::new` built a node whose
|
||||||
|
// window buffer was never corrected -- this is I2's "nothing draws"
|
||||||
|
// bug (RUST.md).
|
||||||
|
//
|
||||||
|
// **Deliberately not `config.width`/`config.height`**: those are
|
||||||
|
// the surface's *physical* pixel size, which the swapchain needs,
|
||||||
|
// but everything downstream of this uniform (layout, hit-testing,
|
||||||
|
// glyph/rect positions) works in the caller's own units -- on
|
||||||
|
// Android that's *logical* (physical / density) since RUST.md's P0
|
||||||
|
// box ("text is far too small"), on desktop it's whatever
|
||||||
|
// `default::render::UiRenderer::new` already divides by
|
||||||
|
// `window.scale_factor()`. Passing it in explicitly, rather than
|
||||||
|
// deriving it from `config` here, is what keeps this crate from
|
||||||
|
// needing to know either platform's notion of density at all.
|
||||||
|
let window_uniform = {
|
||||||
|
let size = window_size.into();
|
||||||
|
WindowUniform {
|
||||||
|
width: size.x,
|
||||||
|
height: size.y,
|
||||||
|
}
|
||||||
|
};
|
||||||
let window_buffer = device.create_buffer_init(&BufferInitDescriptor {
|
let window_buffer = device.create_buffer_init(&BufferInitDescriptor {
|
||||||
label: Some("window"),
|
label: Some("window"),
|
||||||
contents: bytemuck::cast_slice(&[window_uniform]),
|
contents: bytemuck::cast_slice(&[window_uniform]),
|
||||||
@@ -161,34 +397,53 @@ impl UiRenderNode {
|
|||||||
let uniform_group = Self::bind_group_0(device, &uniform_layout, &window_buffer);
|
let uniform_group = Self::bind_group_0(device, &uniform_layout, &window_buffer);
|
||||||
|
|
||||||
let primitive_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
|
let primitive_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
|
||||||
entries: &core::array::from_fn::<_, { PrimitiveBuffers::LEN }, _>(|i| {
|
entries: &PrimitiveBuffers::BINDINGS.map(|binding| BindGroupLayoutEntry {
|
||||||
BindGroupLayoutEntry {
|
binding,
|
||||||
binding: i as u32,
|
visibility: ShaderStages::FRAGMENT,
|
||||||
visibility: ShaderStages::FRAGMENT,
|
ty: BindingType::Buffer {
|
||||||
ty: BindingType::Buffer {
|
ty: BufferBindingType::Storage { read_only: true },
|
||||||
ty: BufferBindingType::Storage { read_only: true },
|
has_dynamic_offset: false,
|
||||||
has_dynamic_offset: false,
|
min_binding_size: None,
|
||||||
min_binding_size: None,
|
},
|
||||||
},
|
count: None,
|
||||||
count: None,
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
label: Some("primitive"),
|
label: Some("primitive"),
|
||||||
});
|
});
|
||||||
|
|
||||||
let tex_manager = GpuTextures::new(device, queue);
|
let tex_manager = GpuTextures::new(device, queue);
|
||||||
|
let primitives = PrimitiveBuffers::new(device);
|
||||||
|
let primitive_group =
|
||||||
|
Self::primitive_group(device, &primitive_layout, primitives.buffers());
|
||||||
|
let instances = ArrBuf::new(
|
||||||
|
device,
|
||||||
|
BufferUsages::STORAGE | BufferUsages::COPY_DST,
|
||||||
|
"ui instances",
|
||||||
|
);
|
||||||
let masks = ArrBuf::new(
|
let masks = ArrBuf::new(
|
||||||
device,
|
device,
|
||||||
BufferUsages::STORAGE | BufferUsages::COPY_DST,
|
BufferUsages::STORAGE | BufferUsages::COPY_DST,
|
||||||
"ui masks",
|
"ui masks",
|
||||||
);
|
);
|
||||||
|
let move_offsets = ArrBuf::new(
|
||||||
|
device,
|
||||||
|
BufferUsages::STORAGE | BufferUsages::COPY_DST,
|
||||||
|
"ui move offsets",
|
||||||
|
);
|
||||||
|
|
||||||
let rsc_layout = Self::rsc_layout(device, &limits);
|
let rsc_layout = Self::rsc_layout(device);
|
||||||
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager, &masks);
|
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager);
|
||||||
|
let masks_layout = Self::masks_layout(device);
|
||||||
|
let masks_group =
|
||||||
|
Self::masks_group(device, &masks_layout, &masks, &move_offsets, &instances);
|
||||||
|
|
||||||
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
|
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
|
||||||
label: Some("UI Shape Pipeline Layout"),
|
label: Some("UI Shape Pipeline Layout"),
|
||||||
bind_group_layouts: &[&uniform_layout, &primitive_layout, &rsc_layout],
|
bind_group_layouts: &[
|
||||||
|
Some(&uniform_layout),
|
||||||
|
Some(&primitive_layout),
|
||||||
|
Some(&rsc_layout),
|
||||||
|
Some(&masks_layout),
|
||||||
|
],
|
||||||
immediate_size: 0,
|
immediate_size: 0,
|
||||||
});
|
});
|
||||||
let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
|
let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
|
||||||
@@ -197,7 +452,7 @@ impl UiRenderNode {
|
|||||||
vertex: VertexState {
|
vertex: VertexState {
|
||||||
module: &shader,
|
module: &shader,
|
||||||
entry_point: Some("vs_main"),
|
entry_point: Some("vs_main"),
|
||||||
buffers: &[PrimitiveInstance::desc()],
|
buffers: &[Some(instance_slot_layout())],
|
||||||
compilation_options: Default::default(),
|
compilation_options: Default::default(),
|
||||||
},
|
},
|
||||||
fragment: Some(FragmentState {
|
fragment: Some(FragmentState {
|
||||||
@@ -229,9 +484,22 @@ impl UiRenderNode {
|
|||||||
cache: None,
|
cache: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
// Reverse of the push order above. Only one of these should ever be
|
||||||
|
// `Some` in practice -- three separate scopes exist to name *which*
|
||||||
|
// kind of error it was, not because more than one is expected at
|
||||||
|
// once.
|
||||||
|
let internal_err = internal_scope.pop().block_on();
|
||||||
|
let validation_err = validation_scope.pop().block_on();
|
||||||
|
let oom_err = oom_scope.pop().block_on();
|
||||||
|
if let Some(err) = validation_err.or(oom_err).or(internal_err) {
|
||||||
|
return Err(err.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
uniform_group,
|
uniform_group,
|
||||||
primitive_layout,
|
primitive_layout,
|
||||||
|
primitives,
|
||||||
|
primitive_group,
|
||||||
rsc_layout,
|
rsc_layout,
|
||||||
rsc_group,
|
rsc_group,
|
||||||
pipeline,
|
pipeline,
|
||||||
@@ -239,8 +507,12 @@ impl UiRenderNode {
|
|||||||
layers: HashMap::default(),
|
layers: HashMap::default(),
|
||||||
active: Vec::new(),
|
active: Vec::new(),
|
||||||
textures: tex_manager,
|
textures: tex_manager,
|
||||||
|
instances,
|
||||||
masks,
|
masks,
|
||||||
}
|
move_offsets,
|
||||||
|
masks_layout,
|
||||||
|
masks_group,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bind_group_0(
|
fn bind_group_0(
|
||||||
@@ -273,7 +545,14 @@ impl UiRenderNode {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rsc_layout(device: &Device, limits: &UiLimits) -> BindGroupLayout {
|
/// Group 2: the shared atlas array and one standalone-image slot (a null
|
||||||
|
/// view for the main draw, a real one for each image's own bind group --
|
||||||
|
/// see `GpuTextures`), plus one sampler. No `count` on any entry: this
|
||||||
|
/// needs nothing beyond plain Vulkan 1.0 / GLES sampling, unlike the
|
||||||
|
/// `binding_array` layout it replaced (see TEXTURES.md's "Recommended
|
||||||
|
/// shape"). Masks and move_offsets are deliberately *not* here -- see
|
||||||
|
/// `masks_layout` below for why they get their own group.
|
||||||
|
fn rsc_layout(device: &Device) -> BindGroupLayout {
|
||||||
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
|
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
|
||||||
entries: &[
|
entries: &[
|
||||||
BindGroupLayoutEntry {
|
BindGroupLayoutEntry {
|
||||||
@@ -281,20 +560,91 @@ impl UiRenderNode {
|
|||||||
visibility: ShaderStages::FRAGMENT,
|
visibility: ShaderStages::FRAGMENT,
|
||||||
ty: BindingType::Texture {
|
ty: BindingType::Texture {
|
||||||
sample_type: TextureSampleType::Float { filterable: false },
|
sample_type: TextureSampleType::Float { filterable: false },
|
||||||
view_dimension: TextureViewDimension::D2,
|
view_dimension: TextureViewDimension::D2Array,
|
||||||
multisampled: false,
|
multisampled: false,
|
||||||
},
|
},
|
||||||
count: Some(NonZero::new(limits.max_textures).unwrap()),
|
count: None,
|
||||||
},
|
},
|
||||||
BindGroupLayoutEntry {
|
BindGroupLayoutEntry {
|
||||||
binding: 1,
|
binding: 1,
|
||||||
visibility: ShaderStages::FRAGMENT,
|
visibility: ShaderStages::FRAGMENT,
|
||||||
ty: BindingType::Sampler(SamplerBindingType::NonFiltering),
|
ty: BindingType::Texture {
|
||||||
count: Some(NonZero::new(limits.max_samplers).unwrap()),
|
sample_type: TextureSampleType::Float { filterable: false },
|
||||||
|
view_dimension: TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
},
|
},
|
||||||
BindGroupLayoutEntry {
|
BindGroupLayoutEntry {
|
||||||
binding: 2,
|
binding: 2,
|
||||||
visibility: ShaderStages::FRAGMENT,
|
visibility: ShaderStages::FRAGMENT,
|
||||||
|
ty: BindingType::Sampler(SamplerBindingType::NonFiltering),
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
label: Some("ui rsc"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The main group: rects and glyphs never sample the image slot, so it
|
||||||
|
/// gets a 1x1 null view rather than any live standalone image's.
|
||||||
|
fn rsc_group(
|
||||||
|
device: &Device,
|
||||||
|
layout: &BindGroupLayout,
|
||||||
|
tex_manager: &GpuTextures,
|
||||||
|
) -> BindGroup {
|
||||||
|
device.create_bind_group(&BindGroupDescriptor {
|
||||||
|
layout,
|
||||||
|
entries: &[
|
||||||
|
BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: BindingResource::TextureView(tex_manager.array_view()),
|
||||||
|
},
|
||||||
|
BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: BindingResource::TextureView(tex_manager.null_view()),
|
||||||
|
},
|
||||||
|
BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: BindingResource::Sampler(tex_manager.sampler()),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
label: Some("ui rsc"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group 3: the masks and move_offsets storage buffers, shared by the
|
||||||
|
/// main draw and every standalone image alike (see the field comment on
|
||||||
|
/// `masks_group`). Bound once per frame in `draw()` rather than folded
|
||||||
|
/// into group 2, so a resize of either buffer -- which an unrelated
|
||||||
|
/// widget's first move slot can trigger -- rebuilds this one group
|
||||||
|
/// instead of every image's.
|
||||||
|
fn masks_layout(device: &Device) -> BindGroupLayout {
|
||||||
|
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
|
||||||
|
entries: &[
|
||||||
|
BindGroupLayoutEntry {
|
||||||
|
binding: 0,
|
||||||
|
visibility: ShaderStages::FRAGMENT,
|
||||||
|
ty: BindingType::Buffer {
|
||||||
|
ty: BufferBindingType::Storage { read_only: true },
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
BindGroupLayoutEntry {
|
||||||
|
binding: 1,
|
||||||
|
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
|
||||||
|
ty: BindingType::Buffer {
|
||||||
|
ty: BufferBindingType::Storage { read_only: true },
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
BindGroupLayoutEntry {
|
||||||
|
binding: 2,
|
||||||
|
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
|
||||||
ty: BindingType::Buffer {
|
ty: BindingType::Buffer {
|
||||||
ty: BufferBindingType::Storage { read_only: true },
|
ty: BufferBindingType::Storage { read_only: true },
|
||||||
has_dynamic_offset: false,
|
has_dynamic_offset: false,
|
||||||
@@ -303,60 +653,67 @@ impl UiRenderNode {
|
|||||||
count: None,
|
count: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
label: Some("ui rsc"),
|
label: Some("ui masks"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rsc_group(
|
fn masks_group(
|
||||||
device: &Device,
|
device: &Device,
|
||||||
layout: &BindGroupLayout,
|
layout: &BindGroupLayout,
|
||||||
tex_manager: &GpuTextures,
|
|
||||||
masks: &ArrBuf<Mask>,
|
masks: &ArrBuf<Mask>,
|
||||||
|
move_offsets: &ArrBuf<MoveOffset>,
|
||||||
|
instances: &ArrBuf<PrimitiveInstance>,
|
||||||
) -> BindGroup {
|
) -> BindGroup {
|
||||||
device.create_bind_group(&BindGroupDescriptor {
|
device.create_bind_group(&BindGroupDescriptor {
|
||||||
layout,
|
layout,
|
||||||
entries: &[
|
entries: &[
|
||||||
BindGroupEntry {
|
BindGroupEntry {
|
||||||
binding: 0,
|
binding: 0,
|
||||||
resource: BindingResource::TextureViewArray(&tex_manager.views()),
|
resource: masks.buffer.as_entire_binding(),
|
||||||
},
|
},
|
||||||
BindGroupEntry {
|
BindGroupEntry {
|
||||||
binding: 1,
|
binding: 1,
|
||||||
resource: BindingResource::SamplerArray(&tex_manager.samplers()),
|
resource: move_offsets.buffer.as_entire_binding(),
|
||||||
},
|
},
|
||||||
BindGroupEntry {
|
BindGroupEntry {
|
||||||
binding: 2,
|
binding: 2,
|
||||||
resource: masks.buffer.as_entire_binding(),
|
resource: instances.buffer.as_entire_binding(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
label: Some("ui rsc"),
|
label: Some("ui masks"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn view_count(&self) -> usize {
|
pub fn view_count(&self) -> usize {
|
||||||
self.textures.view_count()
|
self.textures.view_count()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub struct UiLimits {
|
/// Standalone-image bind groups built since the last call -- see
|
||||||
max_textures: u32,
|
/// `GpuTextures::take_bind_group_creates`. Call once per frame before
|
||||||
max_samplers: u32,
|
/// `update()` to measure exactly that frame.
|
||||||
}
|
pub fn take_image_bind_group_creates(&mut self) -> u64 {
|
||||||
|
self.textures.take_bind_group_creates()
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for UiLimits {
|
/// Atlas-array `grow_array` calls since the last call -- same calling
|
||||||
fn default() -> Self {
|
/// convention as `take_image_bind_group_creates` (call once per frame,
|
||||||
Self {
|
/// before `update()`, to read exactly the previous frame's tally). Part
|
||||||
max_textures: 100000,
|
/// of the Diagnostics page's per-frame report (RUST.md's P0 box, "the
|
||||||
max_samplers: 1000,
|
/// 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UiLimits {
|
/// What `UiRenderNode::update` changed this frame that a caller building a
|
||||||
pub fn max_binding_array_elements_per_shader_stage(&self) -> u32 {
|
/// per-frame diagnostic report cares about -- see `take_image_bind_group_creates`/
|
||||||
self.max_textures + self.max_samplers
|
/// `take_atlas_pages_grown` for the two counters this doesn't carry (they
|
||||||
}
|
/// use the existing "call before update()" convention instead, so as not
|
||||||
pub fn max_binding_array_sampler_elements_per_shader_stage(&self) -> u32 {
|
/// to disturb `bench_images`' documented counts).
|
||||||
self.max_samplers
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
}
|
pub struct FrameUpdateStats {
|
||||||
|
pub masks_resized: bool,
|
||||||
|
pub moves_resized: bool,
|
||||||
}
|
}
|
||||||
+402
-76
@@ -4,35 +4,27 @@ use crate::{
|
|||||||
Color, UiRegion, WidgetId,
|
Color, UiRegion, WidgetId,
|
||||||
render::{
|
render::{
|
||||||
ArrBuf,
|
ArrBuf,
|
||||||
data::{MaskIdx, PrimitiveInstance},
|
data::{MaskIdx, MoveIdx, PrimitiveInstance},
|
||||||
},
|
},
|
||||||
|
util::HashSet,
|
||||||
};
|
};
|
||||||
use bytemuck::Pod;
|
use bytemuck::Pod;
|
||||||
use wgpu::*;
|
use wgpu::*;
|
||||||
|
|
||||||
pub struct Primitives {
|
/// The `binding` tag `Painter` writes on an image instance. Distinct from any
|
||||||
instances: Vec<PrimitiveInstance>,
|
/// `Primitive::BINDING` because images have no `PrimitiveData` entry to key
|
||||||
assoc: Vec<WidgetId>,
|
/// one from -- a bind group already selects the texture -- so this only ever
|
||||||
data: PrimitiveData,
|
/// has to match the shader's `TEXTURE` constant and flag "this instance is
|
||||||
free: Vec<usize>,
|
/// drawn with its own bind group" to the code below.
|
||||||
pub updated: bool,
|
pub const IMAGE_BINDING: u32 = 1;
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Primitives {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
instances: Default::default(),
|
|
||||||
assoc: Default::default(),
|
|
||||||
data: Default::default(),
|
|
||||||
free: Vec::new(),
|
|
||||||
updated: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait Primitive: Pod {
|
pub trait Primitive: Pod {
|
||||||
const BINDING: u32;
|
const BINDING: u32;
|
||||||
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self>;
|
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self>;
|
||||||
|
/// The read-only half of [`Self::vec`], for a caller that wants to
|
||||||
|
/// look one entry up rather than write one -- a mask reading the
|
||||||
|
/// radius of the rect it clips to ([`Primitives::data`]).
|
||||||
|
fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec<Self>;
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! primitives {
|
macro_rules! primitives {
|
||||||
@@ -54,6 +46,14 @@ macro_rules! primitives {
|
|||||||
|
|
||||||
impl PrimitiveBuffers {
|
impl PrimitiveBuffers {
|
||||||
pub const LEN: usize = primitives!(@count $($name)*);
|
pub const LEN: usize = primitives!(@count $($name)*);
|
||||||
|
/// The group-1 binding number each primitive's storage buffer
|
||||||
|
/// sits at, in declaration order. Not `0..LEN`: a primitive's
|
||||||
|
/// `BINDING` also tags its instances for the shader's dispatch
|
||||||
|
/// switch, and a removed primitive (as `TEXTURE` was, once
|
||||||
|
/// images stopped needing a per-instance storage entry) can
|
||||||
|
/// leave a gap, so the pipeline layout has to ask for these
|
||||||
|
/// exact numbers rather than assuming they are contiguous.
|
||||||
|
pub const BINDINGS: [u32; Self::LEN] = [$(<$ty>::BINDING,)*];
|
||||||
pub fn buffers(&self) -> [(u32, &Buffer); Self::LEN] {
|
pub fn buffers(&self) -> [(u32, &Buffer); Self::LEN] {
|
||||||
[
|
[
|
||||||
$((<$ty>::BINDING, &self.$name.buffer),)*
|
$((<$ty>::BINDING, &self.$name.buffer),)*
|
||||||
@@ -90,73 +90,236 @@ macro_rules! primitives {
|
|||||||
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self> {
|
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self> {
|
||||||
&mut data.$name
|
&mut data.$name
|
||||||
}
|
}
|
||||||
|
fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec<Self> {
|
||||||
|
&data.$name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)*
|
)*
|
||||||
};
|
};
|
||||||
(@count $t1:tt $($t:tt)+) => { 1 + primitives!(@count $($t),+) };
|
// The recursion has to hand back the same shape it matches -- space
|
||||||
|
// separated, not comma separated. Written with `$($t),+` it re-entered
|
||||||
|
// with a comma as the first token and never terminated, which happened to
|
||||||
|
// work only because there were exactly two primitives: the first step left
|
||||||
|
// a single token, and a single token matches the base case whichever
|
||||||
|
// separator it was written with.
|
||||||
|
(@count $t1:tt $($t:tt)+) => { 1 + primitives!(@count $($t)+) };
|
||||||
(@count $t:tt) => { 1 };
|
(@count $t:tt) => { 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PrimitiveInst<P> {
|
/// Every primitive instance in the tree, in one arena that all layers
|
||||||
pub id: WidgetId,
|
/// share, plus the per-primitive data (`rects`, `glyphs`) they index.
|
||||||
pub primitive: P,
|
///
|
||||||
pub region: UiRegion,
|
/// **Why one arena rather than one per layer**, which is what this was:
|
||||||
pub mask_idx: MaskIdx,
|
/// the fragment stage evaluates a *mask's* primitive at the masked pixel
|
||||||
|
/// (LAYOUT.md's "Masks with a shape"), and the widget that owns a mask is
|
||||||
|
/// routinely in a different layer from the content it clips -- a rounded
|
||||||
|
/// container in one layer, a `Stack`'s child content in the layer below.
|
||||||
|
/// A per-layer buffer cannot answer that lookup at all: only one layer's
|
||||||
|
/// group is bound at a time, so the mask would silently read another
|
||||||
|
/// layer's rect. Both buffers are therefore global and bound once per
|
||||||
|
/// frame, and a layer keeps only its draw *order* ([`LayerOrder`]).
|
||||||
|
///
|
||||||
|
/// Slots are stable for a primitive's whole life: nothing here is
|
||||||
|
/// compacted, so a `Mask` can hold a slot across frames.
|
||||||
|
pub struct Primitives {
|
||||||
|
instances: Vec<PrimitiveInstance>,
|
||||||
|
assoc: Vec<WidgetId>,
|
||||||
|
/// Where each slot's [`PrimitiveHandle`] sits in its owner's
|
||||||
|
/// `ActiveData::primitives` -- the index that makes
|
||||||
|
/// `UiRenderState::apply_free` O(1) per renumbered primitive instead
|
||||||
|
/// of a scan of everything the owner drew. Written by
|
||||||
|
/// [`Self::set_handle_index`] from the one place a handle is taken
|
||||||
|
/// into that vec (`Painter::own`), and dead alongside its `assoc`
|
||||||
|
/// entry, which is what keeps the two in step.
|
||||||
|
///
|
||||||
|
/// Without it a text widget that is freed and redrawn in one frame
|
||||||
|
/// costs O(glyphs^2): every one of its glyphs is renumbered, and each
|
||||||
|
/// renumbering scanned all of them. Measured 2026-09-08 at 1.37s for a
|
||||||
|
/// 51,200-glyph block on this machine, against 20ms for the shaping
|
||||||
|
/// and rasterising of the same text.
|
||||||
|
handle_idx: Vec<u32>,
|
||||||
|
/// Slots freed since the last [`Self::apply_free`]. Deliberately not
|
||||||
|
/// reusable yet: the layer that drew one still names it in its draw
|
||||||
|
/// order until that call compacts the order, so handing it out again
|
||||||
|
/// first would draw the new primitive twice -- once through the stale
|
||||||
|
/// order entry and once through the new one.
|
||||||
|
freed: Vec<usize>,
|
||||||
|
/// Slots [`Self::apply_free`] released, which is what [`Self::alloc`]
|
||||||
|
/// hands out.
|
||||||
|
reusable: Vec<usize>,
|
||||||
|
data: PrimitiveData,
|
||||||
|
/// Whether the instance arena or the per-primitive data changed since
|
||||||
|
/// the last upload -- one flag for both, since they are uploaded
|
||||||
|
/// together.
|
||||||
|
pub updated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Primitives {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
instances: Default::default(),
|
||||||
|
assoc: Default::default(),
|
||||||
|
handle_idx: Default::default(),
|
||||||
|
freed: Vec::new(),
|
||||||
|
reusable: Vec::new(),
|
||||||
|
data: Default::default(),
|
||||||
|
updated: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Primitives {
|
impl Primitives {
|
||||||
pub fn write<P: Primitive>(
|
/// A slot whose handle has not been recorded yet -- see
|
||||||
|
/// [`Self::handle_idx`]. No owner draws four billion primitives, so
|
||||||
|
/// the sentinel cannot collide with a real index.
|
||||||
|
const NO_HANDLE: u32 = u32::MAX;
|
||||||
|
|
||||||
|
/// Writes a primitive into the arena and hands back its slot and its
|
||||||
|
/// entry in the per-primitive data. The caller (`UiRenderState`) puts
|
||||||
|
/// the slot into a layer's draw order -- an instance that no layer
|
||||||
|
/// names is never rasterized, which is what a mask shape drawn only to
|
||||||
|
/// be *referenced* uses.
|
||||||
|
pub fn alloc<P: Primitive>(
|
||||||
&mut self,
|
&mut self,
|
||||||
layer: usize,
|
|
||||||
PrimitiveInst {
|
PrimitiveInst {
|
||||||
id,
|
id,
|
||||||
primitive,
|
primitive,
|
||||||
region,
|
region,
|
||||||
mask_idx,
|
mask_idx,
|
||||||
|
move_idx,
|
||||||
}: PrimitiveInst<P>,
|
}: PrimitiveInst<P>,
|
||||||
) -> PrimitiveHandle {
|
) -> (u32, usize) {
|
||||||
|
let data_idx = P::vec(&mut self.data).add(primitive);
|
||||||
|
let slot = self.push(
|
||||||
|
PrimitiveInstance {
|
||||||
|
region,
|
||||||
|
idx: data_idx as u32,
|
||||||
|
mask_idx,
|
||||||
|
move_idx,
|
||||||
|
binding: P::BINDING,
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
(slot, data_idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A standalone image, which has no `PrimitiveData` entry to allocate
|
||||||
|
/// -- its bind group already picks the texture, so `texture_idx` rides
|
||||||
|
/// in the otherwise-unused `idx` field and names the bind group the
|
||||||
|
/// draw call selects.
|
||||||
|
pub fn alloc_image(
|
||||||
|
&mut self,
|
||||||
|
id: WidgetId,
|
||||||
|
texture_idx: u32,
|
||||||
|
region: UiRegion,
|
||||||
|
mask_idx: MaskIdx,
|
||||||
|
move_idx: MoveIdx,
|
||||||
|
) -> u32 {
|
||||||
|
self.push(
|
||||||
|
PrimitiveInstance {
|
||||||
|
region,
|
||||||
|
idx: texture_idx,
|
||||||
|
mask_idx,
|
||||||
|
move_idx,
|
||||||
|
binding: IMAGE_BINDING,
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&mut self, inst: PrimitiveInstance, id: WidgetId) -> u32 {
|
||||||
self.updated = true;
|
self.updated = true;
|
||||||
let vec = P::vec(&mut self.data);
|
let slot = if let Some(i) = self.reusable.pop() {
|
||||||
let i = vec.add(primitive);
|
|
||||||
let inst = PrimitiveInstance {
|
|
||||||
region,
|
|
||||||
idx: i as u32,
|
|
||||||
mask_idx,
|
|
||||||
binding: P::BINDING,
|
|
||||||
};
|
|
||||||
let inst_i = if let Some(i) = self.free.pop() {
|
|
||||||
self.instances[i] = inst;
|
self.instances[i] = inst;
|
||||||
self.assoc[i] = id;
|
self.assoc[i] = id;
|
||||||
|
self.handle_idx[i] = Self::NO_HANDLE;
|
||||||
i
|
i
|
||||||
} else {
|
} else {
|
||||||
let i = self.instances.len();
|
|
||||||
self.instances.push(inst);
|
self.instances.push(inst);
|
||||||
self.assoc.push(id);
|
self.assoc.push(id);
|
||||||
i
|
self.handle_idx.push(Self::NO_HANDLE);
|
||||||
|
self.instances.len() - 1
|
||||||
};
|
};
|
||||||
PrimitiveHandle::new::<P>(layer, inst_i, i)
|
slot as u32
|
||||||
}
|
|
||||||
|
|
||||||
/// returns (old index, new index)
|
|
||||||
pub fn apply_free(&mut self) -> impl Iterator<Item = PrimitiveChange> {
|
|
||||||
self.free.sort_by(|a, b| b.cmp(a));
|
|
||||||
self.free.drain(..).filter_map(|i| {
|
|
||||||
self.instances.swap_remove(i);
|
|
||||||
self.assoc.swap_remove(i);
|
|
||||||
if i == self.instances.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let id = self.assoc[i];
|
|
||||||
let old = self.instances.len();
|
|
||||||
Some(PrimitiveChange { id, old, new: i })
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retires a slot, answering the mask it was drawn under so the caller
|
||||||
|
/// can drop that mask's ref. The slot itself only becomes reusable at
|
||||||
|
/// the next [`Self::apply_free`] -- see `freed`.
|
||||||
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
|
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
|
||||||
self.updated = true;
|
self.updated = true;
|
||||||
self.data.free(h.binding, h.data_idx);
|
let slot = h.slot as usize;
|
||||||
self.free.push(h.inst_idx);
|
if h.binding != IMAGE_BINDING {
|
||||||
self.instances[h.inst_idx].mask_idx
|
self.data.free(h.binding, h.data_idx);
|
||||||
|
}
|
||||||
|
self.freed.push(slot);
|
||||||
|
self.instances[slot].mask_idx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hands this frame's freed slots back for reuse. Called once per
|
||||||
|
/// frame from `UiRenderState::update`, **after** every layer has
|
||||||
|
/// compacted its draw order, since that order is the only thing still
|
||||||
|
/// naming them.
|
||||||
|
pub fn release_freed(&mut self) {
|
||||||
|
self.reusable.append(&mut self.freed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which widget drew the primitive in `slot` -- how a draw-order
|
||||||
|
/// change finds the handle it has to renumber.
|
||||||
|
pub fn owner(&self, slot: u32) -> WidgetId {
|
||||||
|
self.assoc[slot as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records that `slot`'s handle is `idx` entries into its owner's
|
||||||
|
/// `ActiveData::primitives`. Called once per primitive, by the one
|
||||||
|
/// place that puts a handle into that vec.
|
||||||
|
pub fn set_handle_index(&mut self, slot: u32, idx: u32) {
|
||||||
|
self.handle_idx[slot as usize] = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where `slot`'s handle sits in its owner's `ActiveData::primitives`
|
||||||
|
/// -- see [`Self::handle_idx`]. `None` only for a slot whose owner
|
||||||
|
/// never took the handle, which nothing in this crate does.
|
||||||
|
pub fn handle_index(&self, slot: u32) -> Option<usize> {
|
||||||
|
match self.handle_idx[slot as usize] {
|
||||||
|
Self::NO_HANDLE => None,
|
||||||
|
idx => Some(idx as usize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.updated = true;
|
||||||
|
self.instances.clear();
|
||||||
|
self.assoc.clear();
|
||||||
|
self.handle_idx.clear();
|
||||||
|
self.freed.clear();
|
||||||
|
self.reusable.clear();
|
||||||
|
self.data.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many instances are still live -- the O(1) half of the orphan
|
||||||
|
/// check, so the O(primitives) walk below only runs on a frame that
|
||||||
|
/// already looks wrong. See
|
||||||
|
/// [`crate::UiRenderState::orphaned_primitives`].
|
||||||
|
pub fn live_count(&self) -> usize {
|
||||||
|
self.instances.len() - self.freed.len() - self.reusable.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every live instance as `(slot, owner, is_image)` -- everything
|
||||||
|
/// except the freed and the reusable. Only
|
||||||
|
/// [`crate::UiRenderState::orphaned_primitives`] uses this, to check
|
||||||
|
/// that every live primitive still belongs to a live widget.
|
||||||
|
pub fn live_instances(&self) -> impl Iterator<Item = (u32, WidgetId, bool)> + '_ {
|
||||||
|
let dead: HashSet<usize> = self.freed.iter().chain(&self.reusable).copied().collect();
|
||||||
|
(0..self.instances.len())
|
||||||
|
.filter(move |i| !dead.contains(i))
|
||||||
|
.map(|i| {
|
||||||
|
(
|
||||||
|
i as u32,
|
||||||
|
self.assoc[i],
|
||||||
|
self.instances[i].binding == IMAGE_BINDING,
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn data(&self) -> &PrimitiveData {
|
pub fn data(&self) -> &PrimitiveData {
|
||||||
@@ -167,40 +330,166 @@ impl Primitives {
|
|||||||
&self.instances
|
&self.instances
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn instance(&self, slot: u32) -> &PrimitiveInstance {
|
||||||
|
&self.instances[slot as usize]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The per-primitive data behind `slot`, or `None` if that slot holds
|
||||||
|
/// a different kind of primitive -- the `binding` check is the same
|
||||||
|
/// one the shader's dispatch switch makes, and it is what stops a
|
||||||
|
/// caller reading a glyph's index into the rect table.
|
||||||
|
pub fn primitive_data<P: Primitive>(&self, slot: u32) -> Option<&P> {
|
||||||
|
let inst = self.instance(slot);
|
||||||
|
(inst.binding == P::BINDING).then(|| &P::vec_ref(&self.data)[inst.idx as usize])
|
||||||
|
}
|
||||||
|
|
||||||
pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion {
|
pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion {
|
||||||
self.updated = true;
|
self.updated = true;
|
||||||
&mut self.instances[h.inst_idx].region
|
&mut self.instances[h.slot as usize].region
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PrimitiveChange {
|
/// One layer's draw order: the slots of the global arena it draws, in the
|
||||||
pub id: WidgetId,
|
/// order they were written. The vertex buffer of a layer is exactly this.
|
||||||
pub old: usize,
|
///
|
||||||
pub new: usize,
|
/// Both lists free with `swap_remove`, so a layer's draw order was already
|
||||||
|
/// undefined before this split: nothing here may assume one primitive
|
||||||
|
/// stays adjacent to another once anything in the layer has been freed.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct LayerOrder {
|
||||||
|
order: Vec<u32>,
|
||||||
|
/// Standalone images, kept apart because each draws with its own bind
|
||||||
|
/// group rather than sharing the layer's one instanced draw -- see
|
||||||
|
/// `UiRenderNode::draw`.
|
||||||
|
images: Vec<u32>,
|
||||||
|
free: Vec<usize>,
|
||||||
|
image_free: Vec<usize>,
|
||||||
|
pub updated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl LayerOrder {
|
||||||
|
pub fn push(&mut self, slot: u32, is_image: bool) -> usize {
|
||||||
|
self.updated = true;
|
||||||
|
let list = if is_image {
|
||||||
|
&mut self.images
|
||||||
|
} else {
|
||||||
|
&mut self.order
|
||||||
|
};
|
||||||
|
list.push(slot);
|
||||||
|
list.len() - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks a position for removal. Deferred to [`Self::apply_free`] like
|
||||||
|
/// the arena's own, so that a position is only renumbered once per
|
||||||
|
/// frame however many were dropped.
|
||||||
|
pub fn free(&mut self, pos: usize, is_image: bool) {
|
||||||
|
self.updated = true;
|
||||||
|
if is_image {
|
||||||
|
self.image_free.push(pos);
|
||||||
|
} else {
|
||||||
|
self.free.push(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compacts both lists, answering every primitive whose position
|
||||||
|
/// moved so its handle can be corrected.
|
||||||
|
pub fn apply_free(&mut self) -> Vec<OrderChange> {
|
||||||
|
let mut changes = Self::apply_free_list(&mut self.free, &mut self.order, false);
|
||||||
|
changes.extend(Self::apply_free_list(
|
||||||
|
&mut self.image_free,
|
||||||
|
&mut self.images,
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
changes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_free_list(
|
||||||
|
free: &mut Vec<usize>,
|
||||||
|
list: &mut Vec<u32>,
|
||||||
|
is_image: bool,
|
||||||
|
) -> Vec<OrderChange> {
|
||||||
|
// Descending, so removing a contiguous tail costs no renumbering
|
||||||
|
// at all -- which is what freeing one widget's primitives is.
|
||||||
|
free.sort_by(|a, b| b.cmp(a));
|
||||||
|
free.drain(..)
|
||||||
|
.filter_map(|pos| {
|
||||||
|
list.swap_remove(pos);
|
||||||
|
if pos == list.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(OrderChange {
|
||||||
|
slot: list[pos],
|
||||||
|
is_image,
|
||||||
|
pos,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn order(&self) -> &Vec<u32> {
|
||||||
|
&self.order
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn images(&self) -> &Vec<u32> {
|
||||||
|
&self.images
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A primitive whose position in a layer's draw order moved when
|
||||||
|
/// something before it was freed -- `slot` names which primitive, so its
|
||||||
|
/// owner's handle can be found and pointed at `pos`.
|
||||||
|
pub struct OrderChange {
|
||||||
|
pub slot: u32,
|
||||||
|
/// Which of the layer's two lists moved: their positions are
|
||||||
|
/// independent index spaces, so a handle matching on position alone
|
||||||
|
/// could take an image's renumbering for a rect's.
|
||||||
|
pub is_image: bool,
|
||||||
|
pub pos: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a primitive goes into its layer's draw order. [`Drawn::No`] is
|
||||||
|
/// a primitive written only to be *referenced* -- a mask's shape
|
||||||
|
/// (LAYOUT.md's "Masks with a shape"). It is owned, moved, resized and
|
||||||
|
/// freed exactly like any other; it is simply never rasterized.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum Drawn {
|
||||||
|
Yes,
|
||||||
|
No,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `pos` of a [`Drawn::No`] primitive: it is in no layer's order, so
|
||||||
|
/// there is no position to renumber or free.
|
||||||
|
pub const NOT_DRAWN: usize = usize::MAX;
|
||||||
|
|
||||||
|
/// Where one primitive lives: its stable slot in the global arena, and
|
||||||
|
/// where in a layer's draw order it currently sits ([`NOT_DRAWN`] if it is
|
||||||
|
/// only referenced).
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct PrimitiveHandle {
|
pub struct PrimitiveHandle {
|
||||||
pub layer: usize,
|
pub layer: usize,
|
||||||
pub inst_idx: usize,
|
pub pos: usize,
|
||||||
|
pub slot: u32,
|
||||||
pub data_idx: usize,
|
pub data_idx: usize,
|
||||||
pub binding: u32,
|
pub binding: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PrimitiveHandle {
|
impl PrimitiveHandle {
|
||||||
fn new<P: Primitive>(layer: usize, inst_idx: usize, data_idx: usize) -> Self {
|
pub fn is_image(&self) -> bool {
|
||||||
Self {
|
self.binding == IMAGE_BINDING
|
||||||
layer,
|
|
||||||
inst_idx,
|
|
||||||
data_idx,
|
|
||||||
binding: P::BINDING,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct PrimitiveInst<P> {
|
||||||
|
pub id: WidgetId,
|
||||||
|
pub primitive: P,
|
||||||
|
pub region: UiRegion,
|
||||||
|
pub mask_idx: MaskIdx,
|
||||||
|
pub move_idx: MoveIdx,
|
||||||
|
}
|
||||||
|
|
||||||
primitives!(
|
primitives!(
|
||||||
rects: RectPrimitive => 0,
|
rects: RectPrimitive => 0,
|
||||||
textures: TexturePrimitive => 1,
|
glyphs: GlyphPrimitive => 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
@@ -223,11 +512,48 @@ impl RectPrimitive {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One glyph, drawn as a sub-rectangle of the glyph atlas array.
|
||||||
|
///
|
||||||
|
/// `color` is the text colour and is multiplied by the atlas's alpha for an
|
||||||
|
/// ordinary mask glyph; a colour glyph (emoji) carries its own colour and
|
||||||
|
/// takes the atlas texel unchanged, which is what `IS_COLOR` selects.
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
#[derive(Debug, Copy, Clone)]
|
#[derive(Debug, Copy, Clone)]
|
||||||
pub struct TexturePrimitive {
|
pub struct GlyphPrimitive {
|
||||||
pub view_idx: u32,
|
pub uv_min: [f32; 2],
|
||||||
pub sampler_idx: u32,
|
pub uv_max: [f32; 2],
|
||||||
|
/// Layer of the shared atlas array texture this glyph's page occupies --
|
||||||
|
/// not a bind-group or view index, since a page never gets one of its
|
||||||
|
/// own. See TEXTURES.md's "Recommended shape".
|
||||||
|
pub layer: u32,
|
||||||
|
pub color: Color<u8>,
|
||||||
|
pub flags: u32,
|
||||||
|
/// Pads this struct's Rust size to match WGSL's storage-buffer layout for
|
||||||
|
/// `GlyphInfo`: two `vec2<f32>` members give the struct an 8-byte
|
||||||
|
/// alignment, which rounds the WGSL size up to 32 bytes even though the
|
||||||
|
/// fields above only total 28. `bytemuck` does not check this for us.
|
||||||
|
_pad: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GlyphPrimitive {
|
||||||
|
pub const IS_COLOR: u32 = 1;
|
||||||
|
|
||||||
|
pub fn new(
|
||||||
|
uv_min: [f32; 2],
|
||||||
|
uv_max: [f32; 2],
|
||||||
|
layer: u32,
|
||||||
|
color: Color<u8>,
|
||||||
|
flags: u32,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
uv_min,
|
||||||
|
uv_max,
|
||||||
|
layer,
|
||||||
|
color,
|
||||||
|
flags,
|
||||||
|
_pad: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PrimitiveVec<T> {
|
pub struct PrimitiveVec<T> {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//! The rounded-rect coverage function, on the CPU.
|
||||||
|
//!
|
||||||
|
//! `shader.wgsl`'s `distance_from_rect`/`rounded_rect_coverage` are a
|
||||||
|
//! transliteration of these two, line for line, and
|
||||||
|
//! `mask_sdf_matches_the_shader` in `iris`'s layout tests compares the two
|
||||||
|
//! at a grid of points against values the shader itself produced. They are
|
||||||
|
//! kept together here, in the crate both a renderer and a hit test can
|
||||||
|
//! reach, because LAYOUT.md's "Masks with a shape" turns on the two
|
||||||
|
//! agreeing: a masked corner that cannot be tapped and a masked corner
|
||||||
|
//! that is not drawn have to be the same corner, and they are only the
|
||||||
|
//! same corner while one function decides both.
|
||||||
|
//!
|
||||||
|
//! Window pixels throughout, matching the shader's `pos` -- not `UiRegion`
|
||||||
|
//! units, which the shader has already resolved by the time it evaluates
|
||||||
|
//! this.
|
||||||
|
|
||||||
|
use crate::util::Vec2;
|
||||||
|
|
||||||
|
/// The signed distance from `pos` to a rounded rect given by its centre,
|
||||||
|
/// its corner offset (half its size) and its corner `radius`. Negative
|
||||||
|
/// inside.
|
||||||
|
pub fn distance_from_rect(pos: Vec2, center: Vec2, corner: Vec2, radius: f32) -> f32 {
|
||||||
|
// vec from center to pixel
|
||||||
|
let p = pos - center;
|
||||||
|
// vec from inner rect corner to pixel
|
||||||
|
let q = Vec2::new(
|
||||||
|
p.x.abs() - (corner.x - radius),
|
||||||
|
p.y.abs() - (corner.y - radius),
|
||||||
|
);
|
||||||
|
let clamped = Vec2::new(q.x.max(0.0), q.y.max(0.0));
|
||||||
|
(clamped.x * clamped.x + clamped.y * clamped.y).sqrt() - radius
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much of the pixel at `pos` a rounded rect covers, anti-aliased over
|
||||||
|
/// the half-pixel either side of its edge: 1 well inside, 0 well outside.
|
||||||
|
///
|
||||||
|
/// The half-pixel feather is why a hit test asks for **more than a half**
|
||||||
|
/// rather than "any coverage at all": half is where the geometric edge is,
|
||||||
|
/// so the two answer the same question the drawn shape does.
|
||||||
|
pub fn rounded_rect_coverage(pos: Vec2, top_left: Vec2, bot_right: Vec2, radius: f32) -> f32 {
|
||||||
|
let edge: f32 = 0.5;
|
||||||
|
let corner = (bot_right - top_left) / 2.0;
|
||||||
|
let center = top_left + corner;
|
||||||
|
let dist = distance_from_rect(pos, center, corner, radius);
|
||||||
|
1.0 - smoothstep(-edge.min(radius), edge, dist)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WGSL's `smoothstep`, which Rust has no equivalent of. Undefined in WGSL
|
||||||
|
/// when `low == high`, which is why the caller above never passes a zero
|
||||||
|
/// radius into the low edge without `edge` bounding it.
|
||||||
|
fn smoothstep(low: f32, high: f32, x: f32) -> f32 {
|
||||||
|
let t = ((x - low) / (high - low)).clamp(0.0, 1.0);
|
||||||
|
t * t * (3.0 - 2.0 * t)
|
||||||
|
}
|
||||||
+193
-53
@@ -1,12 +1,16 @@
|
|||||||
const RECT: u32 = 0u;
|
const RECT: u32 = 0u;
|
||||||
|
// TEXTURE has no entry in group 1: a standalone image draws with its own
|
||||||
|
// bind group (see UiRenderNode::draw), so there is nothing per-instance left
|
||||||
|
// to look up here -- the bind group already picked the texture.
|
||||||
const TEXTURE: u32 = 1u;
|
const TEXTURE: u32 = 1u;
|
||||||
|
const GLYPH: u32 = 2u;
|
||||||
|
|
||||||
@group(0) @binding(0)
|
@group(0) @binding(0)
|
||||||
var<uniform> window: WindowUniform;
|
var<uniform> window: WindowUniform;
|
||||||
@group(1) @binding(RECT)
|
@group(1) @binding(RECT)
|
||||||
var<storage> rects: array<Rect>;
|
var<storage> rects: array<Rect>;
|
||||||
@group(1) @binding(TEXTURE)
|
@group(1) @binding(GLYPH)
|
||||||
var<storage> textures: array<TextureInfo>;
|
var<storage> glyphs: array<GlyphInfo>;
|
||||||
|
|
||||||
struct Rect {
|
struct Rect {
|
||||||
color: u32,
|
color: u32,
|
||||||
@@ -15,14 +19,30 @@ struct Rect {
|
|||||||
inner_radius: f32,
|
inner_radius: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TextureInfo {
|
struct GlyphInfo {
|
||||||
view_idx: u32,
|
uv_min: vec2<f32>,
|
||||||
sampler_idx: u32,
|
uv_max: vec2<f32>,
|
||||||
|
// Layer of the shared atlas array texture, not a view or bind-group
|
||||||
|
// index -- a page never gets its own bind group. See TEXTURES.md's
|
||||||
|
// "Recommended shape".
|
||||||
|
layer: u32,
|
||||||
|
color: u32,
|
||||||
|
flags: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mirrors `Mask` in data.rs: the slot of the primitive whose coverage
|
||||||
|
/// clips this mask's subtree, and the mask it nests inside
|
||||||
|
/// (`4294967295u` at the top).
|
||||||
struct Mask {
|
struct Mask {
|
||||||
x: UiSpan,
|
primitive: u32,
|
||||||
y: UiSpan,
|
parent: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One widget's cumulative on-screen translation and the slot of the
|
||||||
|
/// ancestor to add on top of it. Mirrors `MoveOffset` in data.rs.
|
||||||
|
struct MoveOffset {
|
||||||
|
delta: vec2<f32>,
|
||||||
|
parent: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UiSpan {
|
struct UiSpan {
|
||||||
@@ -35,39 +55,93 @@ struct UiScalar {
|
|||||||
abs: f32,
|
abs: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UiVec2 {
|
// The shared glyph atlas: every page is one layer. Growing it recreates this
|
||||||
rel: vec2<f32>,
|
// texture with headroom and copies the old layers across -- see
|
||||||
abs: vec2<f32>,
|
// GpuTextures::grow_array -- rather than the binding_array<texture_2d<f32>>
|
||||||
}
|
// this replaced, which needed VK_EXT_descriptor_indexing and does not survive
|
||||||
|
// a real share of Android GPUs (see TEXTURES.md).
|
||||||
@group(2) @binding(0)
|
@group(2) @binding(0)
|
||||||
var views: binding_array<texture_2d<f32>>;
|
var atlas: texture_2d_array<f32>;
|
||||||
|
// One standalone image's texture. The main draw (rects and glyphs) binds a
|
||||||
|
// 1x1 null texture here, since neither samples it; each image draw call
|
||||||
|
// binds its own -- see UiRenderNode::draw.
|
||||||
@group(2) @binding(1)
|
@group(2) @binding(1)
|
||||||
var samplers: binding_array<sampler>;
|
var image_texture: texture_2d<f32>;
|
||||||
@group(2) @binding(2)
|
@group(2) @binding(2)
|
||||||
|
var samp: sampler;
|
||||||
|
// Their own group, bound once per frame rather than folded into group 2: see
|
||||||
|
// UiRenderNode::masks_layout for why an image's own bind group must not name
|
||||||
|
// either buffer.
|
||||||
|
@group(3) @binding(0)
|
||||||
var<storage> masks: array<Mask>;
|
var<storage> masks: array<Mask>;
|
||||||
|
@group(3) @binding(1)
|
||||||
|
var<storage> move_offsets: array<MoveOffset>;
|
||||||
|
// Every primitive's placement, in one arena all layers share. The vertex
|
||||||
|
// stage reads the primitive it is drawing (its slot arrives as the only
|
||||||
|
// vertex attribute); the fragment stage reads a *mask's* primitive, which
|
||||||
|
// is generally a different one in a different layer. See LAYOUT.md's
|
||||||
|
// "Masks with a shape" and `Primitives` in primitive.rs.
|
||||||
|
@group(3) @binding(2)
|
||||||
|
var<storage> instances: array<PrimitiveInstance>;
|
||||||
|
|
||||||
|
// The bound on the parent walk, kept in step with `PARENT_CHAIN_LIMIT` in
|
||||||
|
// render_state.rs, which walks the identical chain on the CPU side for
|
||||||
|
// hit-testing. Bounded so a malformed chain (a cyclic `parent`) cannot
|
||||||
|
// hang the GPU -- not a claim about how deep a real tree gets. It was 16
|
||||||
|
// and that was too small: the transcript screen's composer field sits 17
|
||||||
|
// slots below the root, measured 2026-09-07 on this checkout's emulator
|
||||||
|
// by tapping it (the CPU walk's own debug assert names the chain now).
|
||||||
|
// Past the bound both walks simply stop summing, so the widget draws and
|
||||||
|
// hit-tests short by whatever the outer slots held, with nothing on
|
||||||
|
// screen to say so.
|
||||||
|
const PARENT_CHAIN_LIMIT: u32 = 64u;
|
||||||
|
|
||||||
|
/// Sums the pixel delta along the parent chain starting at `idx`, shared by
|
||||||
|
/// the vertex stage (a primitive's own corners) and the fragment stage (its
|
||||||
|
/// mask's corners) so the walk is written once. See LAYOUT.md section 2b.
|
||||||
|
fn resolve_move(idx: u32) -> vec2<f32> {
|
||||||
|
var total = vec2<f32>(0.0, 0.0);
|
||||||
|
var i = idx;
|
||||||
|
for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) {
|
||||||
|
let entry = move_offsets[i];
|
||||||
|
total += entry.delta;
|
||||||
|
if entry.parent == 4294967295u {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i = entry.parent;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
struct WindowUniform {
|
struct WindowUniform {
|
||||||
dim: vec2<f32>,
|
dim: vec2<f32>,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Mirrors `PrimitiveInstance` in data.rs -- the placement and what to
|
||||||
|
/// draw there. `x`/`y` are the `UiRegion`'s two spans.
|
||||||
|
struct PrimitiveInstance {
|
||||||
|
x: UiSpan,
|
||||||
|
y: UiSpan,
|
||||||
|
binding: u32,
|
||||||
|
idx: u32,
|
||||||
|
mask_idx: u32,
|
||||||
|
move_idx: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A layer's draw order: one slot into `instances` per instance drawn.
|
||||||
struct InstanceInput {
|
struct InstanceInput {
|
||||||
@location(0) x_start: vec2<f32>,
|
@location(0) slot: u32,
|
||||||
@location(1) x_end: vec2<f32>,
|
|
||||||
@location(2) y_start: vec2<f32>,
|
|
||||||
@location(3) y_end: vec2<f32>,
|
|
||||||
@location(4) binding: u32,
|
|
||||||
@location(5) idx: u32,
|
|
||||||
@location(6) mask_idx: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VertexOutput {
|
struct VertexOutput {
|
||||||
@location(0) top_left: vec2<f32>,
|
@location(0) top_left: vec2<f32>,
|
||||||
@location(1) bot_right: vec2<f32>,
|
@location(1) bot_right: vec2<f32>,
|
||||||
@location(2) uv: vec2<f32>,
|
@location(2) uv: vec2<f32>,
|
||||||
@location(3) binding: u32,
|
// `flat` is the only interpolation an integer can have, and naga
|
||||||
@location(4) idx: u32,
|
// (wgpu 30) now requires saying so rather than inferring it.
|
||||||
@location(5) mask_idx: u32,
|
@location(3) @interpolate(flat) binding: u32,
|
||||||
|
@location(4) @interpolate(flat) idx: u32,
|
||||||
|
@location(5) @interpolate(flat) mask_idx: u32,
|
||||||
@builtin(position) clip_position: vec4<f32>,
|
@builtin(position) clip_position: vec4<f32>,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,20 +152,38 @@ struct Region {
|
|||||||
bot_right: vec2<f32>,
|
bot_right: vec2<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One primitive's on-screen corners in window pixels. Written once and
|
||||||
|
/// used by both stages: the vertex stage for the primitive it is drawing,
|
||||||
|
/// the fragment stage for a mask's -- so the shape a mask clips to and the
|
||||||
|
/// shape that was drawn cannot be computed two different ways.
|
||||||
|
struct Corners {
|
||||||
|
top_left: vec2<f32>,
|
||||||
|
bot_right: vec2<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn corners_of(inst: PrimitiveInstance) -> Corners {
|
||||||
|
let top_left_rel = vec2(inst.x.start.rel, inst.y.start.rel);
|
||||||
|
let top_left_abs = vec2(inst.x.start.abs, inst.y.start.abs);
|
||||||
|
let bot_right_rel = vec2(inst.x.end.rel, inst.y.end.rel);
|
||||||
|
let bot_right_abs = vec2(inst.x.end.abs, inst.y.end.abs);
|
||||||
|
let move_delta = resolve_move(inst.move_idx);
|
||||||
|
return Corners(
|
||||||
|
floor(top_left_rel * window.dim) + floor(top_left_abs) + move_delta,
|
||||||
|
floor(bot_right_rel * window.dim) + floor(bot_right_abs) + move_delta,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@vertex
|
@vertex
|
||||||
fn vs_main(
|
fn vs_main(
|
||||||
@builtin(vertex_index) vi: u32,
|
@builtin(vertex_index) vi: u32,
|
||||||
in: InstanceInput,
|
in: InstanceInput,
|
||||||
) -> VertexOutput {
|
) -> VertexOutput {
|
||||||
var out: VertexOutput;
|
var out: VertexOutput;
|
||||||
|
let inst = instances[in.slot];
|
||||||
|
|
||||||
let top_left_rel = vec2(in.x_start.x, in.y_start.x);
|
let c = corners_of(inst);
|
||||||
let top_left_abs = vec2(in.x_start.y, in.y_start.y);
|
let top_left = c.top_left;
|
||||||
let bot_right_rel = vec2(in.x_end.x, in.y_end.x);
|
let bot_right = c.bot_right;
|
||||||
let bot_right_abs = vec2(in.x_end.y, in.y_end.y);
|
|
||||||
|
|
||||||
let top_left = floor(top_left_rel * window.dim) + floor(top_left_abs);
|
|
||||||
let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_abs);
|
|
||||||
let size = bot_right - top_left;
|
let size = bot_right - top_left;
|
||||||
|
|
||||||
let uv = vec2<f32>(
|
let uv = vec2<f32>(
|
||||||
@@ -101,11 +193,11 @@ fn vs_main(
|
|||||||
let pos = (top_left + uv * size) / window.dim * 2.0 - 1.0;
|
let pos = (top_left + uv * size) / window.dim * 2.0 - 1.0;
|
||||||
out.clip_position = vec4<f32>(pos.x, -pos.y, 0.0, 1.0);
|
out.clip_position = vec4<f32>(pos.x, -pos.y, 0.0, 1.0);
|
||||||
out.uv = uv;
|
out.uv = uv;
|
||||||
out.binding = in.binding;
|
out.binding = inst.binding;
|
||||||
out.idx = in.idx;
|
out.idx = inst.idx;
|
||||||
out.top_left = top_left;
|
out.top_left = top_left;
|
||||||
out.bot_right = bot_right;
|
out.bot_right = bot_right;
|
||||||
out.mask_idx = in.mask_idx;
|
out.mask_idx = inst.mask_idx;
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -123,29 +215,79 @@ fn fs_main(
|
|||||||
color = draw_rounded_rect(region, rects[i]);
|
color = draw_rounded_rect(region, rects[i]);
|
||||||
}
|
}
|
||||||
case TEXTURE: {
|
case TEXTURE: {
|
||||||
color = draw_texture(region, textures[i]);
|
color = draw_texture(region);
|
||||||
|
}
|
||||||
|
case GLYPH: {
|
||||||
|
color = draw_glyph(region, glyphs[i]);
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
color = vec4(1.0, 0.0, 1.0, 1.0);
|
color = vec4(1.0, 0.0, 1.0, 1.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if in.mask_idx != 4294967295u {
|
// Every mask on the chain, not just the innermost: a widget that set
|
||||||
let mask = masks[in.mask_idx];
|
// its own mask inside another is clipped by both, and the coverages
|
||||||
let tl = UiVec2(vec2(mask.x.start.rel, mask.y.start.rel), vec2(mask.x.start.abs, mask.y.start.abs));
|
// multiply -- so a pixel inside two feathered corners is dimmed by
|
||||||
let br = UiVec2(vec2(mask.x.end.rel, mask.y.end.rel), vec2(mask.x.end.abs, mask.y.end.abs));
|
// both, which is what a compositor does (`Mask::parent` in data.rs).
|
||||||
|
var mask_idx = in.mask_idx;
|
||||||
let top_left = floor(tl.rel * window.dim) + floor(tl.abs);
|
for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) {
|
||||||
let bot_right = floor(br.rel * window.dim) + floor(br.abs);
|
if mask_idx == 4294967295u {
|
||||||
if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y {
|
break;
|
||||||
color *= 0.0;
|
|
||||||
}
|
}
|
||||||
|
let mask = masks[mask_idx];
|
||||||
|
color.a *= mask_coverage(pos, mask);
|
||||||
|
mask_idx = mask.parent;
|
||||||
}
|
}
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: this seems really inefficient (per frag indexing)?
|
/// How much of `pos` one mask lets through: the referenced primitive's
|
||||||
fn draw_texture(region: Region, info: TextureInfo) -> vec4<f32> {
|
/// own coverage at that pixel, from the same SDF the primitive is drawn
|
||||||
return textureSample(views[info.view_idx], samplers[info.sampler_idx], region.uv);
|
/// with. Nothing about the shape is copied into the mask, so a rounded
|
||||||
|
/// container's corner and its children's clipped corner are the same
|
||||||
|
/// arithmetic.
|
||||||
|
fn mask_coverage(pos: vec2<f32>, mask: Mask) -> f32 {
|
||||||
|
let inst = instances[mask.primitive];
|
||||||
|
if inst.binding != RECT {
|
||||||
|
// Unreachable: `Painter::set_mask` rejects a glyph or an image
|
||||||
|
// shape by name (see `Mask::primitive`). Letting the pixel
|
||||||
|
// through rather than reading a `rects` entry that is not there.
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
let c = corners_of(inst);
|
||||||
|
return rounded_rect_coverage(pos, c.top_left, c.bot_right, rects[inst.idx].radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_texture(region: Region) -> vec4<f32> {
|
||||||
|
return textureSample(image_texture, samp, region.uv);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_glyph(region: Region, g: GlyphInfo) -> vec4<f32> {
|
||||||
|
let uv = mix(g.uv_min, g.uv_max, region.uv);
|
||||||
|
let texel = textureSample(atlas, samp, uv, i32(g.layer));
|
||||||
|
if (g.flags & 1u) != 0u {
|
||||||
|
return texel;
|
||||||
|
}
|
||||||
|
var color = unpack4x8unorm(g.color);
|
||||||
|
color.a *= texel.a;
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The anti-aliased coverage of a rounded rect at one pixel -- the one
|
||||||
|
/// function both a drawn rect and a mask go through, and the
|
||||||
|
/// transliteration of `iris_core::rounded_rect_coverage` on the CPU,
|
||||||
|
/// which the hit test uses so a corner that cannot be tapped and a corner
|
||||||
|
/// that is not drawn are the same corner.
|
||||||
|
fn rounded_rect_coverage(
|
||||||
|
pos: vec2<f32>,
|
||||||
|
top_left: vec2<f32>,
|
||||||
|
bot_right: vec2<f32>,
|
||||||
|
radius: f32,
|
||||||
|
) -> f32 {
|
||||||
|
let edge = 0.5;
|
||||||
|
let corner = (bot_right - top_left) / 2.0;
|
||||||
|
let center = top_left + corner;
|
||||||
|
let dist = distance_from_rect(pos, center, corner, radius);
|
||||||
|
return 1.0 - smoothstep(-min(edge, radius), edge, dist);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
|
fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
|
||||||
@@ -153,14 +295,12 @@ fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
|
|||||||
|
|
||||||
let edge = 0.5;
|
let edge = 0.5;
|
||||||
|
|
||||||
let size = region.bot_right - region.top_left;
|
color.a *= rounded_rect_coverage(region.pos, region.top_left, region.bot_right, rect.radius);
|
||||||
let corner = size / 2.0;
|
|
||||||
let center = region.top_left + corner;
|
|
||||||
|
|
||||||
let dist = distance_from_rect(region.pos, center, corner, rect.radius);
|
|
||||||
color.a *= 1.0 - smoothstep(-min(edge, rect.radius), edge, dist);
|
|
||||||
|
|
||||||
if rect.thickness > 0.0 {
|
if rect.thickness > 0.0 {
|
||||||
|
let size = region.bot_right - region.top_left;
|
||||||
|
let corner = size / 2.0;
|
||||||
|
let center = region.top_left + corner;
|
||||||
let dist2 = distance_from_rect(region.pos, center, corner - rect.thickness, rect.inner_radius);
|
let dist2 = distance_from_rect(region.pos, center, corner - rect.thickness, rect.inner_radius);
|
||||||
color.a *= smoothstep(-min(edge, rect.inner_radius), edge, dist2);
|
color.a *= smoothstep(-min(edge, rect.inner_radius), edge, dist2);
|
||||||
}
|
}
|
||||||
|
|||||||
+430
-55
@@ -1,59 +1,306 @@
|
|||||||
use image::{DynamicImage, EncodableLayout};
|
use image::{DynamicImage, EncodableLayout, GenericImageView};
|
||||||
use wgpu::{util::DeviceExt, *};
|
use wgpu::{util::DeviceExt, *};
|
||||||
|
|
||||||
use crate::{TextureUpdate, Textures};
|
use crate::{PatchRect, TextureKind, TextureUpdate, Textures};
|
||||||
|
|
||||||
|
use super::atlas::PAGE;
|
||||||
|
|
||||||
|
/// The fewest layers the glyph atlas array is ever created with. Two, not
|
||||||
|
/// one, for the GLES reason written on `create_array_texture`.
|
||||||
|
const MIN_ARRAY_LAYERS: u32 = 2;
|
||||||
|
|
||||||
|
/// What one texture slot is, GPU-side. Parallel to `Textures`' own slot
|
||||||
|
/// numbering (`TextureKind`'s `Image`/`Page`), so a slot's index means the
|
||||||
|
/// same thing on both sides without a second map to keep in sync.
|
||||||
|
enum Slot {
|
||||||
|
/// A slot that was freed, or pushed and freed within the same batch
|
||||||
|
/// before ever reaching here.
|
||||||
|
Empty,
|
||||||
|
Image(ImageGpu),
|
||||||
|
/// The array layer a page occupies. Pages are never freed (see
|
||||||
|
/// `Textures::free`), so this is the only variant that outlives a `Free`.
|
||||||
|
Page(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ImageGpu {
|
||||||
|
/// Kept alive alongside `view`/`bind_group`, which borrow from it only in
|
||||||
|
/// the sense that dropping this drops the GPU resource they point to.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
texture: Texture,
|
||||||
|
view: TextureView,
|
||||||
|
bind_group: BindGroup,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns the two kinds of texture iris draws:
|
||||||
|
///
|
||||||
|
/// - **The glyph atlas**, one `texture_2d_array` whose layers are pages
|
||||||
|
/// (`Slot::Page`), grown by recreating the array with headroom and
|
||||||
|
/// `copy_texture_to_texture`-ing the old layers across. No feature beyond
|
||||||
|
/// Vulkan 1.0/GLES sampling is needed for this -- a layer index is an
|
||||||
|
/// ordinary sampling operand.
|
||||||
|
/// - **Standalone images** (`Slot::Image`), each its own `Texture` and
|
||||||
|
/// `BindGroup`, drawn one `draw()` call at a time with that bind group
|
||||||
|
/// bound -- see `UiRenderNode::draw`.
|
||||||
|
///
|
||||||
|
/// See TEXTURES.md's "Recommended shape" for why, and RUST.md's
|
||||||
|
/// "iris's binding array does not survive real Android hardware" for what
|
||||||
|
/// this replaced (one giant `binding_array<texture_2d<f32>>` needing
|
||||||
|
/// `VK_EXT_descriptor_indexing`, which a real share of Android GPUs lack).
|
||||||
pub struct GpuTextures {
|
pub struct GpuTextures {
|
||||||
device: Device,
|
device: Device,
|
||||||
queue: Queue,
|
queue: Queue,
|
||||||
views: Vec<TextureView>,
|
|
||||||
view_count: usize,
|
slots: Vec<Slot>,
|
||||||
samplers: Vec<Sampler>,
|
|
||||||
|
array_texture: Texture,
|
||||||
|
array_view: TextureView,
|
||||||
|
array_capacity: u32,
|
||||||
|
/// Layers actually written. Only grows -- see `Slot::Page`.
|
||||||
|
page_count: u32,
|
||||||
|
|
||||||
|
sampler: Sampler,
|
||||||
|
/// Bound in the image slot of the main draw's bind group, which has
|
||||||
|
/// nothing of its own to put there: rects and glyphs never sample it,
|
||||||
|
/// but the layout requires something bound regardless.
|
||||||
null_view: TextureView,
|
null_view: TextureView,
|
||||||
no_views: Vec<TextureView>,
|
|
||||||
|
/// Standalone-image bind groups actually built (`create_image`'s own
|
||||||
|
/// build, or one per slot touched by `rebuild_image_bind_groups`) since
|
||||||
|
/// the last `take_bind_group_creates`. IRIS_TODO.md's "many images"
|
||||||
|
/// benchmark reads this to prove the steady-state cost of an
|
||||||
|
/// unchanging image list is zero, the same way `UiRenderState`'s
|
||||||
|
/// `draw_count`/`region_mut_count` prove the layout side.
|
||||||
|
bind_group_creates: u64,
|
||||||
|
/// `grow_array` calls since the last `take_pages_grown` -- the
|
||||||
|
/// Diagnostics page's per-frame report (RUST.md's P0 box, "the first
|
||||||
|
/// input frame" investigation) reads this alongside `bind_group_creates`
|
||||||
|
/// to say whether *this* frame's glyph disappearance, if any, coincided
|
||||||
|
/// with the atlas array being recreated.
|
||||||
|
pages_grown: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GpuTextures {
|
impl GpuTextures {
|
||||||
pub fn update(&mut self, textures: &mut Textures) -> bool {
|
/// Applies queued `Textures` updates, then reports whether the *main*
|
||||||
let mut changed = false;
|
/// bind group (the one rects and glyphs draw with) needs rebuilding --
|
||||||
|
/// true exactly when the atlas array was recreated (its view identity
|
||||||
|
/// changed). Pushing or freeing a standalone image never touches that
|
||||||
|
/// group: it built or drops its own. Masks/move_offsets resizing is
|
||||||
|
/// `UiRenderNode`'s own concern now (its `masks_group`, group 3) --
|
||||||
|
/// see that struct's field comment for why standalone images no longer
|
||||||
|
/// hear about either buffer at all.
|
||||||
|
pub fn update(&mut self, textures: &mut Textures, rsc_layout: &BindGroupLayout) -> bool {
|
||||||
|
let mut rebuild_main = false;
|
||||||
for update in textures.updates() {
|
for update in textures.updates() {
|
||||||
changed = true;
|
|
||||||
match update {
|
match update {
|
||||||
TextureUpdate::Push(image) => self.push(image),
|
TextureUpdate::Push(kind, image) => {
|
||||||
TextureUpdate::Set(i, image) => self.set(i, image),
|
rebuild_main |= self.push(kind, image, rsc_layout);
|
||||||
TextureUpdate::SetFree => self.view_count += 1,
|
}
|
||||||
|
TextureUpdate::Set(kind, i, image) => {
|
||||||
|
rebuild_main |= self.set(kind, i, image, rsc_layout);
|
||||||
|
}
|
||||||
|
// A patch changes texture contents, not which layer or bind
|
||||||
|
// group exists, so it never asks for a rebuild -- rebuilding
|
||||||
|
// per glyph is exactly the cost this exists to avoid.
|
||||||
|
TextureUpdate::Patch(i, rect, image) => self.patch(i, rect, image),
|
||||||
|
TextureUpdate::SetFree => {}
|
||||||
TextureUpdate::Free(i) => self.free(i),
|
TextureUpdate::Free(i) => self.free(i),
|
||||||
TextureUpdate::PushFree => self.push_free(),
|
TextureUpdate::PushFree(_kind) => self.slots.push(Slot::Empty),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
changed
|
rebuild_main
|
||||||
}
|
|
||||||
fn set(&mut self, i: u32, image: &DynamicImage) {
|
|
||||||
self.view_count += 1;
|
|
||||||
let view = self.create_view(image);
|
|
||||||
self.views[i as usize] = view;
|
|
||||||
}
|
|
||||||
fn free(&mut self, i: u32) {
|
|
||||||
self.view_count -= 1;
|
|
||||||
self.views[i as usize] = self.null_view.clone();
|
|
||||||
}
|
|
||||||
fn push(&mut self, image: &DynamicImage) {
|
|
||||||
self.view_count += 1;
|
|
||||||
let view = self.create_view(image);
|
|
||||||
self.views.push(view);
|
|
||||||
}
|
|
||||||
fn push_free(&mut self) {
|
|
||||||
self.view_count += 1;
|
|
||||||
self.views.push(self.null_view.clone());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_view(&self, image: &DynamicImage) -> TextureView {
|
fn push(
|
||||||
let image = image.to_rgba8();
|
&mut self,
|
||||||
let (width, height) = image.dimensions();
|
kind: TextureKind,
|
||||||
|
image: &DynamicImage,
|
||||||
|
rsc_layout: &BindGroupLayout,
|
||||||
|
) -> bool {
|
||||||
|
let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout);
|
||||||
|
self.slots.push(slot);
|
||||||
|
rebuilt
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set(
|
||||||
|
&mut self,
|
||||||
|
kind: TextureKind,
|
||||||
|
i: u32,
|
||||||
|
image: &DynamicImage,
|
||||||
|
rsc_layout: &BindGroupLayout,
|
||||||
|
) -> bool {
|
||||||
|
let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout);
|
||||||
|
self.slots[i as usize] = slot;
|
||||||
|
rebuilt
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_slot(
|
||||||
|
&mut self,
|
||||||
|
kind: TextureKind,
|
||||||
|
image: &DynamicImage,
|
||||||
|
rsc_layout: &BindGroupLayout,
|
||||||
|
) -> (Slot, bool) {
|
||||||
|
match kind {
|
||||||
|
TextureKind::Image => {
|
||||||
|
let gpu = self.create_image(image, rsc_layout);
|
||||||
|
(Slot::Image(gpu), false)
|
||||||
|
}
|
||||||
|
TextureKind::Page { layer } => {
|
||||||
|
let mut rebuilt = false;
|
||||||
|
if layer >= self.array_capacity {
|
||||||
|
self.grow_array(rsc_layout);
|
||||||
|
rebuilt = true;
|
||||||
|
}
|
||||||
|
self.write_full_layer(layer, image);
|
||||||
|
self.page_count = self.page_count.max(layer + 1);
|
||||||
|
(Slot::Page(layer), rebuilt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn free(&mut self, i: u32) {
|
||||||
|
if let Some(slot) = self.slots.get_mut(i as usize) {
|
||||||
|
*slot = Slot::Empty;
|
||||||
|
}
|
||||||
|
// A page's layer is not reclaimed here either -- see `Slot::Page`.
|
||||||
|
}
|
||||||
|
|
||||||
|
fn patch(&mut self, i: u32, rect: PatchRect, image: &DynamicImage) {
|
||||||
|
let Some(&Slot::Page(layer)) = self.slots.get(i as usize) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if rect.width == 0 || rect.height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Cropped rather than written straight from the atlas, because
|
||||||
|
// write_texture wants tightly packed rows and the atlas rows are as
|
||||||
|
// wide as the atlas. A glyph is small, so the copy is too.
|
||||||
|
let sub = image
|
||||||
|
.view(rect.x, rect.y, rect.width, rect.height)
|
||||||
|
.to_image();
|
||||||
|
self.queue.write_texture(
|
||||||
|
TexelCopyTextureInfo {
|
||||||
|
texture: &self.array_texture,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: Origin3d {
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
z: layer,
|
||||||
|
},
|
||||||
|
aspect: TextureAspect::All,
|
||||||
|
},
|
||||||
|
sub.as_bytes(),
|
||||||
|
TexelCopyBufferLayout {
|
||||||
|
offset: 0,
|
||||||
|
bytes_per_row: Some(rect.width * 4),
|
||||||
|
rows_per_image: Some(rect.height),
|
||||||
|
},
|
||||||
|
Extent3d {
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_full_layer(&self, layer: u32, image: &DynamicImage) {
|
||||||
|
// Every page is created as exactly PAGE x PAGE (`GlyphAtlas::allocate`),
|
||||||
|
// so this is always a whole-layer write, never a crop.
|
||||||
|
let rgba = image.to_rgba8();
|
||||||
|
self.queue.write_texture(
|
||||||
|
TexelCopyTextureInfo {
|
||||||
|
texture: &self.array_texture,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: Origin3d {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
z: layer,
|
||||||
|
},
|
||||||
|
aspect: TextureAspect::All,
|
||||||
|
},
|
||||||
|
rgba.as_bytes(),
|
||||||
|
TexelCopyBufferLayout {
|
||||||
|
offset: 0,
|
||||||
|
bytes_per_row: Some(PAGE * 4),
|
||||||
|
rows_per_image: Some(PAGE),
|
||||||
|
},
|
||||||
|
Extent3d {
|
||||||
|
width: PAGE,
|
||||||
|
height: PAGE,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Doubles the array's layer capacity (headroom, so this is rare) and
|
||||||
|
/// copies the old layers across GPU-side -- no readback. Recreates the
|
||||||
|
/// array's view, which invalidates every bind group that referenced it,
|
||||||
|
/// so this also rebuilds all of them before returning.
|
||||||
|
fn grow_array(&mut self, rsc_layout: &BindGroupLayout) {
|
||||||
|
self.pages_grown += 1;
|
||||||
|
let new_capacity = self.array_capacity * 2;
|
||||||
|
let new_texture = Self::create_array_texture(&self.device, new_capacity);
|
||||||
|
if self.page_count > 0 {
|
||||||
|
let mut encoder = self
|
||||||
|
.device
|
||||||
|
.create_command_encoder(&CommandEncoderDescriptor {
|
||||||
|
label: Some("atlas array grow"),
|
||||||
|
});
|
||||||
|
encoder.copy_texture_to_texture(
|
||||||
|
TexelCopyTextureInfo {
|
||||||
|
texture: &self.array_texture,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: Origin3d::ZERO,
|
||||||
|
aspect: TextureAspect::All,
|
||||||
|
},
|
||||||
|
TexelCopyTextureInfo {
|
||||||
|
texture: &new_texture,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: Origin3d::ZERO,
|
||||||
|
aspect: TextureAspect::All,
|
||||||
|
},
|
||||||
|
Extent3d {
|
||||||
|
width: PAGE,
|
||||||
|
height: PAGE,
|
||||||
|
depth_or_array_layers: self.page_count,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.queue.submit(std::iter::once(encoder.finish()));
|
||||||
|
}
|
||||||
|
self.array_texture = new_texture;
|
||||||
|
self.array_view = self.array_texture.create_view(&TextureViewDescriptor {
|
||||||
|
dimension: Some(TextureViewDimension::D2Array),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
self.array_capacity = new_capacity;
|
||||||
|
self.rebuild_image_bind_groups(rsc_layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called only from `grow_array`: the atlas array's view identity is the
|
||||||
|
/// one thing an image's bind group (group 2) still names that can
|
||||||
|
/// change out from under it. Masks/move_offsets resizing no longer
|
||||||
|
/// reaches here at all -- see `UiRenderNode::masks_group`.
|
||||||
|
fn rebuild_image_bind_groups(&mut self, rsc_layout: &BindGroupLayout) {
|
||||||
|
for slot in &mut self.slots {
|
||||||
|
if let Slot::Image(gpu) = slot {
|
||||||
|
gpu.bind_group = Self::make_image_bind_group(
|
||||||
|
&self.device,
|
||||||
|
rsc_layout,
|
||||||
|
&self.array_view,
|
||||||
|
&gpu.view,
|
||||||
|
&self.sampler,
|
||||||
|
);
|
||||||
|
self.bind_group_creates += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_image(&mut self, image: &DynamicImage, rsc_layout: &BindGroupLayout) -> ImageGpu {
|
||||||
|
let rgba = image.to_rgba8();
|
||||||
|
let (width, height) = rgba.dimensions();
|
||||||
let texture = self.device.create_texture_with_data(
|
let texture = self.device.create_texture_with_data(
|
||||||
&self.queue,
|
&self.queue,
|
||||||
&TextureDescriptor {
|
&TextureDescriptor {
|
||||||
label: None,
|
label: Some("image"),
|
||||||
size: Extent3d {
|
size: Extent3d {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@@ -63,45 +310,173 @@ impl GpuTextures {
|
|||||||
sample_count: 1,
|
sample_count: 1,
|
||||||
dimension: TextureDimension::D2,
|
dimension: TextureDimension::D2,
|
||||||
format: TextureFormat::Rgba8Unorm,
|
format: TextureFormat::Rgba8Unorm,
|
||||||
usage: TextureUsages::TEXTURE_BINDING,
|
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
|
||||||
view_formats: &[],
|
view_formats: &[],
|
||||||
},
|
},
|
||||||
wgt::TextureDataOrder::MipMajor,
|
wgt::TextureDataOrder::MipMajor,
|
||||||
image.as_bytes(),
|
rgba.as_bytes(),
|
||||||
);
|
);
|
||||||
texture.create_view(&TextureViewDescriptor::default())
|
let view = texture.create_view(&TextureViewDescriptor::default());
|
||||||
|
let bind_group = Self::make_image_bind_group(
|
||||||
|
&self.device,
|
||||||
|
rsc_layout,
|
||||||
|
&self.array_view,
|
||||||
|
&view,
|
||||||
|
&self.sampler,
|
||||||
|
);
|
||||||
|
self.bind_group_creates += 1;
|
||||||
|
ImageGpu {
|
||||||
|
texture,
|
||||||
|
view,
|
||||||
|
bind_group,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds group 2 for one standalone image: the shared atlas array, this
|
||||||
|
/// image's own view and the shared sampler -- the same layout the main
|
||||||
|
/// draw uses with a null view in the image slot. Deliberately does not
|
||||||
|
/// touch masks/move_offsets (group 3, `UiRenderNode::masks_group`): see
|
||||||
|
/// that field's comment for why folding them in here was the bug.
|
||||||
|
fn make_image_bind_group(
|
||||||
|
device: &Device,
|
||||||
|
rsc_layout: &BindGroupLayout,
|
||||||
|
array_view: &TextureView,
|
||||||
|
image_view: &TextureView,
|
||||||
|
sampler: &Sampler,
|
||||||
|
) -> BindGroup {
|
||||||
|
device.create_bind_group(&BindGroupDescriptor {
|
||||||
|
layout: rsc_layout,
|
||||||
|
entries: &[
|
||||||
|
BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: BindingResource::TextureView(array_view),
|
||||||
|
},
|
||||||
|
BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: BindingResource::TextureView(image_view),
|
||||||
|
},
|
||||||
|
BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: BindingResource::Sampler(sampler),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
label: Some("ui rsc image"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The atlas is sampled as a `texture_2d_array`, and **a one-layer
|
||||||
|
/// array is not one on the GLES backend**: wgpu-hal picks the GL
|
||||||
|
/// texture target from the descriptor alone
|
||||||
|
/// (`gles::Texture::get_info_from_desc`, `(false, 1) => TEXTURE_2D`),
|
||||||
|
/// so a capacity of 1 creates a `GL_TEXTURE_2D` and binds it to the
|
||||||
|
/// shader's `sampler2DArray`. GL then treats that unit as incomplete
|
||||||
|
/// and every `textureSample` returns (0, 0, 0, 1) -- which, through
|
||||||
|
/// `draw_glyph`'s `color.a *= texel.a`, draws every glyph as a solid
|
||||||
|
/// filled box. That was iris's appearance on the emulator's GLES for
|
||||||
|
/// two days (RUST.md, "the emulator cannot draw iris's glyphs"), and
|
||||||
|
/// it is a real defect on any device whose adapter is GL rather than
|
||||||
|
/// Vulkan, not an emulator artifact. So the array never has fewer than
|
||||||
|
/// `MIN_ARRAY_LAYERS` layers; the second layer costs one page of
|
||||||
|
/// texture memory and is used by the next atlas page anyway.
|
||||||
|
fn create_array_texture(device: &Device, capacity: u32) -> Texture {
|
||||||
|
debug_assert!(
|
||||||
|
capacity >= MIN_ARRAY_LAYERS,
|
||||||
|
"glyph atlas array asked for {capacity} layers; fewer than {MIN_ARRAY_LAYERS} is a \
|
||||||
|
GL_TEXTURE_2D on the GLES backend and draws every glyph as a box"
|
||||||
|
);
|
||||||
|
device.create_texture(&TextureDescriptor {
|
||||||
|
label: Some("glyph atlas array"),
|
||||||
|
size: Extent3d {
|
||||||
|
width: PAGE,
|
||||||
|
height: PAGE,
|
||||||
|
depth_or_array_layers: capacity,
|
||||||
|
},
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: TextureDimension::D2,
|
||||||
|
format: TextureFormat::Rgba8Unorm,
|
||||||
|
usage: TextureUsages::TEXTURE_BINDING
|
||||||
|
| TextureUsages::COPY_DST
|
||||||
|
| TextureUsages::COPY_SRC,
|
||||||
|
view_formats: &[],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new(device: &Device, queue: &Queue) -> Self {
|
pub fn new(device: &Device, queue: &Queue) -> Self {
|
||||||
|
let sampler = default_sampler(device);
|
||||||
let null_view = null_texture_view(device);
|
let null_view = null_texture_view(device);
|
||||||
|
let array_capacity = MIN_ARRAY_LAYERS;
|
||||||
|
let array_texture = Self::create_array_texture(device, array_capacity);
|
||||||
|
let array_view = array_texture.create_view(&TextureViewDescriptor {
|
||||||
|
dimension: Some(TextureViewDimension::D2Array),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
Self {
|
Self {
|
||||||
device: device.clone(),
|
device: device.clone(),
|
||||||
queue: queue.clone(),
|
queue: queue.clone(),
|
||||||
views: Vec::new(),
|
slots: Vec::new(),
|
||||||
samplers: vec![default_sampler(device)],
|
array_texture,
|
||||||
no_views: vec![null_view.clone()],
|
array_view,
|
||||||
|
array_capacity,
|
||||||
|
page_count: 0,
|
||||||
|
sampler,
|
||||||
null_view,
|
null_view,
|
||||||
view_count: 0,
|
bind_group_creates: 0,
|
||||||
|
pages_grown: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn views(&self) -> Vec<&TextureView> {
|
/// Reads and zeroes the standalone-image bind-group creation counter --
|
||||||
if self.views.is_empty() {
|
/// call once per frame before `update()`, mirroring
|
||||||
&self.no_views
|
/// `UiRenderState::take_counters`.
|
||||||
} else {
|
pub fn take_bind_group_creates(&mut self) -> u64 {
|
||||||
&self.views
|
std::mem::take(&mut self.bind_group_creates)
|
||||||
}
|
|
||||||
.iter()
|
|
||||||
.by_ref()
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn samplers(&self) -> Vec<&Sampler> {
|
/// Reads and zeroes the atlas-array-grow counter -- see `pages_grown`'s
|
||||||
self.samplers.iter().by_ref().collect()
|
/// field comment.
|
||||||
|
pub fn take_pages_grown(&mut self) -> u64 {
|
||||||
|
std::mem::take(&mut self.pages_grown)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn array_view(&self) -> &TextureView {
|
||||||
|
&self.array_view
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn null_view(&self) -> &TextureView {
|
||||||
|
&self.null_view
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sampler(&self) -> &Sampler {
|
||||||
|
&self.sampler
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bind group a standalone image draws with. Panics if `idx` names an
|
||||||
|
/// atlas page or a freed slot instead -- either is a caller bug (the
|
||||||
|
/// wrong kind of instance reached this draw path), not a condition to
|
||||||
|
/// recover from.
|
||||||
|
pub fn image_bind_group(&self, idx: u32) -> &BindGroup {
|
||||||
|
match self.slots.get(idx as usize) {
|
||||||
|
Some(Slot::Image(gpu)) => &gpu.bind_group,
|
||||||
|
other => panic!("texture slot {idx} is not a live standalone image: {other:?}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn view_count(&self) -> usize {
|
pub fn view_count(&self) -> usize {
|
||||||
self.view_count
|
self.slots
|
||||||
|
.iter()
|
||||||
|
.filter(|s| !matches!(s, Slot::Empty))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for Slot {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Slot::Empty => write!(f, "Empty"),
|
||||||
|
Slot::Image(_) => write!(f, "Image"),
|
||||||
|
Slot::Page(layer) => write!(f, "Page(layer={layer})"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,18 @@ impl<T: Pod> ArrBuf<T> {
|
|||||||
_pd: PhantomData,
|
_pd: PhantomData,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn update(&mut self, device: &Device, queue: &Queue, data: &[T]) {
|
/// Returns whether the underlying `Buffer` was recreated -- a caller that
|
||||||
if self.len != data.len() {
|
/// cached a `BindGroup` referencing it (as `GpuTextures` does for the
|
||||||
|
/// masks buffer) needs to know to rebuild that too.
|
||||||
|
pub fn update(&mut self, device: &Device, queue: &Queue, data: &[T]) -> bool {
|
||||||
|
let resized = self.len != data.len();
|
||||||
|
if resized {
|
||||||
self.len = data.len();
|
self.len = data.len();
|
||||||
self.buffer =
|
self.buffer =
|
||||||
Self::init_buf(device, std::mem::size_of_val(data), self.usage, self.label);
|
Self::init_buf(device, std::mem::size_of_val(data), self.usage, self.label);
|
||||||
}
|
}
|
||||||
queue.write_buffer(&self.buffer, 0, bytemuck::cast_slice(data));
|
queue.write_buffer(&self.buffer, 0, bytemuck::cast_slice(data));
|
||||||
|
resized
|
||||||
}
|
}
|
||||||
fn init_buf(device: &Device, size: usize, usage: BufferUsages, label: &'static str) -> Buffer {
|
fn init_buf(device: &Device, size: usize, usage: BufferUsages, label: &'static str) -> Buffer {
|
||||||
let mut size = size as u64;
|
let mut size = size as u64;
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
//! I4 (RUST.md): an AccessKit tree built from iris's own widget tree,
|
||||||
|
//! shared by both backends -- `android/view.rs` pushes its `TreeUpdate`s
|
||||||
|
//! through `accesskit_android::Adapter`, `default/mod.rs` through
|
||||||
|
//! `accesskit_winit::Adapter`. Kept modular the way input's sense registry
|
||||||
|
//! is: `Widgets::named()` is a side set populated only by `.label()`, so a
|
||||||
|
//! widget nobody named is never visited here at all, not even to decide it
|
||||||
|
//! has no name.
|
||||||
|
//!
|
||||||
|
//! The tree itself is deliberately flat -- one synthetic `Role::Window`
|
||||||
|
//! root with every named widget as a direct child, in no particular order.
|
||||||
|
//! iris's actual widget nesting (a label three `Span`s deep inside a
|
||||||
|
//! `ScrollArea`) carries no accessibility meaning of its own here: nothing
|
||||||
|
//! upstream of a named leaf needs a node, since a screen reader's own
|
||||||
|
//! traversal (and uiautomator's tap-by-name, the pass condition this was
|
||||||
|
//! built for) works from each node's on-screen bounds rather than from
|
||||||
|
//! tree structure. Mirroring the real widget tree exactly would also mean
|
||||||
|
//! rebuilding intermediate nodes whenever *any* container above a named
|
||||||
|
//! widget resizes, which is most frames -- the flat shape is what keeps
|
||||||
|
//! rebuilds tied to "a name, a role or a position actually changed".
|
||||||
|
|
||||||
|
use crate::{PixelRegion, UiRenderState, UiRsc, WidgetId, Widgets, util::HashMap};
|
||||||
|
use accesskit::{Node, NodeId, Rect, Role, TreeId, TreeInfo, TreeUpdate};
|
||||||
|
|
||||||
|
/// Reserved for the synthetic root; every real widget's `SlotId::as_u64`
|
||||||
|
/// starts at 1, so this can never collide with one (see that method's
|
||||||
|
/// doc comment).
|
||||||
|
const WINDOW_NODE: NodeId = NodeId(0);
|
||||||
|
|
||||||
|
fn node_id(id: WidgetId) -> NodeId {
|
||||||
|
NodeId(id.as_u64())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
struct Entry {
|
||||||
|
name: String,
|
||||||
|
role: Role,
|
||||||
|
bounds: PixelRegion,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry_node(entry: &Entry) -> Node {
|
||||||
|
let mut node = Node::new(entry.role);
|
||||||
|
node.set_label(entry.name.clone());
|
||||||
|
node.set_bounds(Rect {
|
||||||
|
x0: entry.bounds.top_left.x as f64,
|
||||||
|
y0: entry.bounds.top_left.y as f64,
|
||||||
|
x1: entry.bounds.bot_right.x as f64,
|
||||||
|
y1: entry.bounds.bot_right.y as f64,
|
||||||
|
});
|
||||||
|
node
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns the last tree pushed out, so `update` can tell "nothing
|
||||||
|
/// accessibility-relevant changed" from "something did" without asking
|
||||||
|
/// the platform adapter to diff two `Node`s itself. One of these per
|
||||||
|
/// window/view -- `default::DefaultUiState` and `android::AndroidUiState`
|
||||||
|
/// each keep one.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct AccessTree {
|
||||||
|
known: HashMap<WidgetId, Entry>,
|
||||||
|
/// `TreeUpdate`s actually produced since the last `take_rebuilds` --
|
||||||
|
/// the AccessKit-tree twin of `UiRenderState::take_counters`. Should
|
||||||
|
/// stay at 0 across an unchanged frame and move by exactly 1 when a
|
||||||
|
/// named widget's position, name or role changes, however many other
|
||||||
|
/// widgets are on screen; see `iris/src/access_tests.rs`.
|
||||||
|
rebuilds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccessTree {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect(
|
||||||
|
widgets: &Widgets,
|
||||||
|
render: &UiRenderState,
|
||||||
|
rsc: &dyn UiRsc,
|
||||||
|
) -> HashMap<WidgetId, Entry> {
|
||||||
|
let mut current = HashMap::default();
|
||||||
|
for id in widgets.named() {
|
||||||
|
let Some(bounds) = render.window_region(&id, rsc) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(widget) = widgets.get_dyn(id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
current.insert(
|
||||||
|
id,
|
||||||
|
Entry {
|
||||||
|
name: widgets.label(id).clone(),
|
||||||
|
role: widget.access_role(),
|
||||||
|
bounds,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
current
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walks `widgets.named()`, looks up each one's current screen bounds
|
||||||
|
/// via `render.window_region` (which resolves the same move-chain
|
||||||
|
/// `resolved_region` does, so a moved subtree reports where it
|
||||||
|
/// actually is), and returns a full `TreeUpdate` if and only if that
|
||||||
|
/// set differs from the last call -- added, removed, renamed, or
|
||||||
|
/// moved/resized. A widget that is named but not currently active
|
||||||
|
/// (not drawn this frame) is left out, the same as one never named at
|
||||||
|
/// all.
|
||||||
|
pub fn update(
|
||||||
|
&mut self,
|
||||||
|
widgets: &Widgets,
|
||||||
|
render: &UiRenderState,
|
||||||
|
rsc: &dyn UiRsc,
|
||||||
|
) -> Option<TreeUpdate> {
|
||||||
|
let current = Self::collect(widgets, render, rsc);
|
||||||
|
if current == self.known {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.known = current.clone();
|
||||||
|
self.rebuilds += 1;
|
||||||
|
Some(build_update(¤t))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The unconditional twin of `update`, for a platform adapter's
|
||||||
|
/// activation handler (`android/access.rs`'s `AndroidAccessSource`) --
|
||||||
|
/// AccessKit asks for a full tree the first time a client attaches,
|
||||||
|
/// which is exactly the case `update`'s diff-against-`known` is not
|
||||||
|
/// meant to answer (it may have already sent this same snapshot to a
|
||||||
|
/// client that has since detached and reattached).
|
||||||
|
pub fn build_full(widgets: &Widgets, render: &UiRenderState, rsc: &dyn UiRsc) -> TreeUpdate {
|
||||||
|
build_update(&Self::collect(widgets, render, rsc))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads and zeroes the rebuild counter, the same call shape as
|
||||||
|
/// `UiRenderState::take_counters`.
|
||||||
|
pub fn take_rebuilds(&mut self) -> u64 {
|
||||||
|
std::mem::take(&mut self.rebuilds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_update(current: &HashMap<WidgetId, Entry>) -> TreeUpdate {
|
||||||
|
let mut window = Node::new(Role::Window);
|
||||||
|
let mut nodes = Vec::with_capacity(current.len() + 1);
|
||||||
|
for (&id, entry) in current {
|
||||||
|
window.push_child(node_id(id));
|
||||||
|
nodes.push((node_id(id), entry_node(entry)));
|
||||||
|
}
|
||||||
|
nodes.push((WINDOW_NODE, window));
|
||||||
|
TreeUpdate {
|
||||||
|
nodes,
|
||||||
|
tree: Some(TreeInfo::new(WINDOW_NODE)),
|
||||||
|
tree_id: TreeId::ROOT,
|
||||||
|
focus: WINDOW_NODE,
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
-1
@@ -1,4 +1,6 @@
|
|||||||
use crate::{LayerId, MaskIdx, PrimitiveHandle, TextureHandle, UiRegion, WidgetId};
|
use crate::{
|
||||||
|
LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId, util::Vec2,
|
||||||
|
};
|
||||||
|
|
||||||
/// important non rendering data for retained drawing
|
/// important non rendering data for retained drawing
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -9,6 +11,59 @@ pub struct ActiveData {
|
|||||||
pub textures: Vec<TextureHandle>,
|
pub textures: Vec<TextureHandle>,
|
||||||
pub primitives: Vec<PrimitiveHandle>,
|
pub primitives: Vec<PrimitiveHandle>,
|
||||||
pub children: Vec<WidgetId>,
|
pub children: Vec<WidgetId>,
|
||||||
|
/// The mask this widget was drawn **under** (its parent's), not the
|
||||||
|
/// one it set for itself -- see `own_mask` for that.
|
||||||
pub mask: MaskIdx,
|
pub mask: MaskIdx,
|
||||||
|
/// The mask slot this widget allocated for *itself* with
|
||||||
|
/// `Painter::set_mask`, or `MaskIdx::NONE`. Kept across redraws and
|
||||||
|
/// rewritten in place, the way `move_slot` is: a `Masked` that pushed
|
||||||
|
/// a fresh slot each draw left every already-drawn descendant --
|
||||||
|
/// which `draw_inner`'s unchanged-region fast path does not revisit --
|
||||||
|
/// clipping to the *old* slot's region, so a composer whose bar had
|
||||||
|
/// since been placed at the bottom of the screen was still being
|
||||||
|
/// clipped to a box at the top of it and drew nothing (measured
|
||||||
|
/// 2026-09-06: four mask entries live, none of them the widget's
|
||||||
|
/// current region). Its path out is the `undraw` branch of
|
||||||
|
/// `UiRenderState::remove`, which drops the self-ownership ref taken
|
||||||
|
/// when the slot was allocated.
|
||||||
|
pub own_mask: MaskIdx,
|
||||||
pub layer: LayerId,
|
pub layer: LayerId,
|
||||||
|
/// What `Widget::draw` returned the last time this widget was actually
|
||||||
|
/// drawn -- read by a parent placing this widget again without
|
||||||
|
/// redrawing it, replacing `Cache.size`'s old role. See LAYOUT.md
|
||||||
|
/// section 5.
|
||||||
|
pub size: Size,
|
||||||
|
/// This widget's slot in `UiData::move_offsets`, assigned on its first
|
||||||
|
/// draw and kept for the rest of its life (redraws reuse it in place
|
||||||
|
/// so a retained child's `parent` link never goes stale). See
|
||||||
|
/// LAYOUT.md section 2.
|
||||||
|
pub move_slot: MoveIdx,
|
||||||
|
/// How much of this widget's own `move_slot` delta is already folded
|
||||||
|
/// into `region` above, in window pixels. The two mechanisms that
|
||||||
|
/// write that slot disagree about this and cannot be told apart from
|
||||||
|
/// the slot alone: `UiRenderState::mov` shifts `region` and the delta
|
||||||
|
/// together (the *offered* region genuinely moved), while
|
||||||
|
/// `Painter::reposition` writes only the delta (`region` stays the
|
||||||
|
/// offered box and the delta says where inside it the content was
|
||||||
|
/// placed). So anything that wants the widget's real position --
|
||||||
|
/// `resolved_region`, and through it every hit test -- must subtract
|
||||||
|
/// this from the chain sum. Without it a panned widget's own hit box
|
||||||
|
/// sits at twice the pan while its descendants' are correct, which is
|
||||||
|
/// how it went unnoticed: the composer's field became untappable
|
||||||
|
/// after a finger pan (2026-09-06). Reset to zero whenever the widget
|
||||||
|
/// is really redrawn, since `draw_inner` zeroes the slot then too.
|
||||||
|
pub move_applied: Vec2,
|
||||||
|
/// The offset the last `Painter::reposition` placed this widget's
|
||||||
|
/// content at *within* `region`, in window pixels. The move slot has
|
||||||
|
/// exactly one owner and one meaning:
|
||||||
|
/// `move_offsets[move_slot] == move_applied + repositioned`. `mov`
|
||||||
|
/// adds to the first, `reposition` overwrites the second (it
|
||||||
|
/// recomputes `from` afresh every call, so repeating it must land on
|
||||||
|
/// the same answer rather than drifting), and both then rewrite the
|
||||||
|
/// slot from the sum -- which is what lets a parent both move a child
|
||||||
|
/// with its own layout and place it inside that moved region in one
|
||||||
|
/// frame. `LazySpan::place`'s Bottom-known branch does exactly that once a
|
||||||
|
/// row's blocks wrap. Reset to zero on a real redraw, with
|
||||||
|
/// `move_applied` and the slot itself.
|
||||||
|
pub repositioned: Vec2,
|
||||||
}
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
use crate::{BothAxis, Len, UiVec2, WidgetId, util::HashMap};
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct Cache {
|
|
||||||
pub size: BothAxis<HashMap<WidgetId, (UiVec2, Len)>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Cache {
|
|
||||||
pub fn remove(&mut self, id: WidgetId) {
|
|
||||||
self.size.x.remove(&id);
|
|
||||||
self.size.y.remove(&id);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
self.size.x.clear();
|
|
||||||
self.size.y.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+51
-4
@@ -1,15 +1,16 @@
|
|||||||
use crate::{Mask, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena};
|
use crate::{
|
||||||
|
Mask, MoveOffset, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena,
|
||||||
|
};
|
||||||
|
|
||||||
|
mod access;
|
||||||
mod active;
|
mod active;
|
||||||
mod cache;
|
|
||||||
mod painter;
|
mod painter;
|
||||||
mod render_state;
|
mod render_state;
|
||||||
mod size;
|
|
||||||
|
|
||||||
|
pub use access::*;
|
||||||
pub use active::*;
|
pub use active::*;
|
||||||
pub use painter::Painter;
|
pub use painter::Painter;
|
||||||
pub use render_state::*;
|
pub use render_state::*;
|
||||||
pub use size::*;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct UiData {
|
pub struct UiData {
|
||||||
@@ -17,6 +18,52 @@ pub struct UiData {
|
|||||||
pub textures: Textures,
|
pub textures: Textures,
|
||||||
pub text: TextData,
|
pub text: TextData,
|
||||||
pub masks: TrackedArena<Mask, u32>,
|
pub masks: TrackedArena<Mask, u32>,
|
||||||
|
/// One entry per widget ever drawn, forming the parent-linked chain
|
||||||
|
/// `resolve_move` walks in both shader stages. Allocated once on a
|
||||||
|
/// widget's first draw and reused for every later redraw of the same
|
||||||
|
/// id (never reallocated), so a retained descendant's `parent` index
|
||||||
|
/// never goes stale -- see LAYOUT.md section 2.
|
||||||
|
pub move_offsets: TrackedArena<MoveOffset, u32>,
|
||||||
|
/// Every widget whose [`crate::Widget::tick`] should run before the
|
||||||
|
/// next frame -- today, a `LazySpan` coasting through a fling. Added by
|
||||||
|
/// [`Self::animate`] when the animation starts and removed by
|
||||||
|
/// [`Self::tick_animations`] the frame its `tick` answers `false`, so
|
||||||
|
/// a stopped animation costs nothing and a dropped widget cannot be
|
||||||
|
/// ticked (`get_dyn_mut` answers `None` and it is dropped the same
|
||||||
|
/// way).
|
||||||
|
animating: Vec<WidgetId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiData {
|
||||||
|
/// Ask for `id`'s [`crate::Widget::tick`] to run every frame until it
|
||||||
|
/// says it is done. Idempotent -- registering an already-animating
|
||||||
|
/// widget is the ordinary case (a second fling before the first
|
||||||
|
/// settled) and must not tick it twice per frame.
|
||||||
|
pub fn animate(&mut self, id: WidgetId) {
|
||||||
|
if !self.animating.contains(&id) {
|
||||||
|
self.animating.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tick every registered widget to `now`, drop the ones that finished,
|
||||||
|
/// and say whether any is still going -- which is a backend's cue to
|
||||||
|
/// ask for another frame. Called once per frame *before* the draw, so
|
||||||
|
/// what the frame draws is this instant's position rather than the
|
||||||
|
/// previous one's.
|
||||||
|
pub fn tick_animations(&mut self, now: std::time::Instant) -> bool {
|
||||||
|
// Taken out and put back rather than iterated in place: `tick`
|
||||||
|
// needs `&mut` on the widget arena this list lives beside, and a
|
||||||
|
// widget is free to register another one while ticking.
|
||||||
|
let mut registered = std::mem::take(&mut self.animating);
|
||||||
|
registered.retain(|&id| match self.widgets.get_dyn_mut(id) {
|
||||||
|
Some(widget) => widget.tick(now),
|
||||||
|
None => false,
|
||||||
|
});
|
||||||
|
for id in registered {
|
||||||
|
self.animate(id);
|
||||||
|
}
|
||||||
|
!self.animating.is_empty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait UiRsc {
|
pub trait UiRsc {
|
||||||
|
|||||||
+283
-33
@@ -1,7 +1,10 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
Axis, Len, RenderedText, Size, SizeCtx, StrongWidget, TextAttrs, TextBuffer, TextData,
|
Color, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle,
|
||||||
TextureHandle, UiRegion, UiRenderState, UiRsc, Widget, WidgetId,
|
UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
|
||||||
render::{Mask, MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst},
|
render::{
|
||||||
|
Drawn, GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst,
|
||||||
|
RectPrimitive,
|
||||||
|
},
|
||||||
util::Vec2,
|
util::Vec2,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -12,6 +15,11 @@ pub struct Painter<'a> {
|
|||||||
|
|
||||||
pub(super) region: UiRegion,
|
pub(super) region: UiRegion,
|
||||||
pub(super) mask: MaskIdx,
|
pub(super) mask: MaskIdx,
|
||||||
|
pub(super) move_slot: MoveIdx,
|
||||||
|
/// This widget's own mask slot, reused across redraws -- see
|
||||||
|
/// `ActiveData::own_mask`. `MaskIdx::NONE` until `set_mask` is called
|
||||||
|
/// for the first time in this widget's life.
|
||||||
|
pub(super) own_mask: MaskIdx,
|
||||||
pub(super) textures: Vec<TextureHandle>,
|
pub(super) textures: Vec<TextureHandle>,
|
||||||
pub(super) primitives: Vec<PrimitiveHandle>,
|
pub(super) primitives: Vec<PrimitiveHandle>,
|
||||||
pub(super) children: Vec<WidgetId>,
|
pub(super) children: Vec<WidgetId>,
|
||||||
@@ -21,19 +29,49 @@ pub struct Painter<'a> {
|
|||||||
|
|
||||||
impl<'a> Painter<'a> {
|
impl<'a> Painter<'a> {
|
||||||
fn primitive_at<P: Primitive>(&mut self, primitive: P, region: UiRegion) {
|
fn primitive_at<P: Primitive>(&mut self, primitive: P, region: UiRegion) {
|
||||||
let h = self.state.layers.write(
|
self.write_primitive(primitive, region, Drawn::Yes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one path every primitive this widget owns goes through --
|
||||||
|
/// drawn or, for a mask's shape, only referenced.
|
||||||
|
fn write_primitive<P: Primitive>(
|
||||||
|
&mut self,
|
||||||
|
primitive: P,
|
||||||
|
region: UiRegion,
|
||||||
|
drawn: Drawn,
|
||||||
|
) -> u32 {
|
||||||
|
let h = self.state.write_primitive(
|
||||||
self.layer,
|
self.layer,
|
||||||
|
drawn,
|
||||||
PrimitiveInst {
|
PrimitiveInst {
|
||||||
id: self.id,
|
id: self.id,
|
||||||
primitive,
|
primitive,
|
||||||
region,
|
region,
|
||||||
mask_idx: self.mask,
|
mask_idx: self.mask,
|
||||||
|
move_idx: self.move_slot,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if self.mask != MaskIdx::NONE {
|
if self.mask != MaskIdx::NONE {
|
||||||
// TODO: I have no clue if this works at all :joy:
|
// TODO: I have no clue if this works at all :joy:
|
||||||
self.rsc.ui_mut().masks.push_ref(self.mask);
|
self.rsc.ui_mut().masks.push_ref(self.mask);
|
||||||
}
|
}
|
||||||
|
let slot = h.slot;
|
||||||
|
self.own(h);
|
||||||
|
slot
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take ownership of a handle this widget just wrote.
|
||||||
|
///
|
||||||
|
/// The one place a `PrimitiveHandle` enters `self.primitives`, and so
|
||||||
|
/// the one place that can keep `Primitives::handle_index` in step with
|
||||||
|
/// where it lands -- which is what `UiRenderState::apply_free` reads
|
||||||
|
/// instead of scanning this vec. Anything that writes a primitive
|
||||||
|
/// without coming through here leaves that index unset, and its
|
||||||
|
/// position in a layer's draw order stops being renumbered.
|
||||||
|
fn own(&mut self, h: PrimitiveHandle) {
|
||||||
|
self.state
|
||||||
|
.primitives
|
||||||
|
.set_handle_index(h.slot, self.primitives.len() as u32);
|
||||||
self.primitives.push(h);
|
self.primitives.push(h);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,75 +84,291 @@ impl<'a> Painter<'a> {
|
|||||||
self.primitive_at(primitive, region.within(&self.region));
|
self.primitive_at(primitive, region.within(&self.region));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clip everything this widget draws, itself and its descendants, to
|
||||||
|
/// `region`. One call per widget; a widget drawn inside another
|
||||||
|
/// widget's mask nests instead -- the new mask chains to the inherited
|
||||||
|
/// one (`Mask::parent`) and the fragment stage multiplies both
|
||||||
|
/// coverages, which is what lets a transcript row's code fence clip
|
||||||
|
/// to itself *and* to the list it scrolls inside.
|
||||||
|
///
|
||||||
|
/// The clip is a **primitive**, not a rectangle copied into the mask:
|
||||||
|
/// this writes an undrawn `RectPrimitive` at `region` and points the
|
||||||
|
/// mask at it, so the fragment stage evaluates the same rounded-rect
|
||||||
|
/// coverage a drawn rect gets. See LAYOUT.md's "Masks with a shape".
|
||||||
|
///
|
||||||
|
/// The slot is allocated once and **rewritten in place** on every
|
||||||
|
/// later draw rather than pushed again, because a descendant whose own
|
||||||
|
/// region did not change is not redrawn (`draw_inner`'s fast path) and
|
||||||
|
/// so keeps pointing at whichever slot it was drawn under. See
|
||||||
|
/// `ActiveData::own_mask` for what pushing a fresh one cost.
|
||||||
pub fn set_mask(&mut self, region: UiRegion) {
|
pub fn set_mask(&mut self, region: UiRegion) {
|
||||||
assert!(self.mask == MaskIdx::NONE);
|
let shape = self.write_primitive(RectPrimitive::color(Color::NONE), region, Drawn::No);
|
||||||
self.mask = self.rsc.ui_mut().masks.push(Mask { region });
|
self.set_mask_to(shape);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draws a widget within this widget's region.
|
/// Clip everything this widget draws after this call to `shape`'s
|
||||||
pub fn widget<W: ?Sized>(&mut self, id: &StrongWidget<W>) {
|
/// own shape -- the first primitive `shape`'s subtree drew, which
|
||||||
self.widget_at(id, self.region);
|
/// must already have been drawn this frame
|
||||||
|
/// (`UiRenderState::first_primitive`). What `.masked_by()` uses to
|
||||||
|
/// clip a container's content to the rounded background it draws,
|
||||||
|
/// with no radius argument anywhere that could fall out of step with
|
||||||
|
/// the one being drawn.
|
||||||
|
pub fn set_mask_to_widget<W: ?Sized>(&mut self, shape: &StrongWidget<W>) {
|
||||||
|
let slot = self.state.first_primitive(shape.id()).unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"'{}' was given as a mask's shape but drew no primitive, so there is nothing to \
|
||||||
|
clip to",
|
||||||
|
self.rsc.widgets().label(shape.id()),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
self.set_mask_to(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Points this widget's mask at a primitive that has already been
|
||||||
|
/// written -- the shared half of [`Self::set_mask`].
|
||||||
|
fn set_mask_to(&mut self, shape: u32) {
|
||||||
|
// `assert!`, not `debug_assert!`: one comparison per widget draw,
|
||||||
|
// and the second call silently *replacing* the first is a widget
|
||||||
|
// drawn unclipped -- which reaches the screen and nothing says so.
|
||||||
|
// Every build anybody runs here is release
|
||||||
|
// (docs/REVIEW-2026-09-07.md's R1).
|
||||||
|
assert!(
|
||||||
|
self.own_mask == MaskIdx::NONE || self.mask != self.own_mask,
|
||||||
|
"set_mask called twice while drawing one widget: the second would replace the first \
|
||||||
|
rather than nest inside it",
|
||||||
|
);
|
||||||
|
// A glyph would need a CPU-side alpha plane for the hit test to
|
||||||
|
// agree with the shader, and a standalone image a bind-group
|
||||||
|
// switch the fragment stage cannot make -- see `Mask::primitive`.
|
||||||
|
// Named here rather than left to the shader, which would read a
|
||||||
|
// rect that is not there and clip to nothing.
|
||||||
|
let binding = self.state.primitives.instance(shape).binding;
|
||||||
|
assert_eq!(
|
||||||
|
binding,
|
||||||
|
RectPrimitive::BINDING,
|
||||||
|
"a mask's shape must be a rect primitive; primitive {shape} is binding {binding}",
|
||||||
|
);
|
||||||
|
let parent = self.mask;
|
||||||
|
let mask = Mask {
|
||||||
|
primitive: shape,
|
||||||
|
parent,
|
||||||
|
};
|
||||||
|
let old_parent = if self.own_mask == MaskIdx::NONE {
|
||||||
|
let slot = self.rsc.ui_mut().masks.push(mask);
|
||||||
|
// The one ref this widget holds on its own slot, so the slot
|
||||||
|
// outlives any single frame's primitives; released in
|
||||||
|
// `UiRenderState::remove`'s `undraw` branch.
|
||||||
|
self.rsc.ui_mut().masks.push_ref(slot);
|
||||||
|
self.own_mask = slot;
|
||||||
|
MaskIdx::NONE
|
||||||
|
} else {
|
||||||
|
let old = self.rsc.ui().masks[self.own_mask.idx()].parent;
|
||||||
|
*self.rsc.ui_mut().masks.get_mut(self.own_mask) = mask;
|
||||||
|
old
|
||||||
|
};
|
||||||
|
// The chain link's own ref, taken before the old one is dropped so
|
||||||
|
// that re-chaining to the same slot cannot free it in between.
|
||||||
|
// Released here when the link changes, and in
|
||||||
|
// `UiRenderState::remove` when this widget's slot goes.
|
||||||
|
if old_parent != parent {
|
||||||
|
if parent != MaskIdx::NONE {
|
||||||
|
self.rsc.ui_mut().masks.push_ref(parent);
|
||||||
|
}
|
||||||
|
if old_parent != MaskIdx::NONE {
|
||||||
|
self.rsc.ui_mut().masks.remove(old_parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.mask = self.own_mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws a widget within this widget's region, returning the size it
|
||||||
|
/// reported using.
|
||||||
|
pub fn widget<W: ?Sized>(&mut self, id: &StrongWidget<W>) -> Size {
|
||||||
|
self.widget_at(id, self.region)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Draws a widget somewhere within this one.
|
/// Draws a widget somewhere within this one.
|
||||||
/// Useful for drawing child widgets in select areas.
|
/// Useful for drawing child widgets in select areas.
|
||||||
pub fn widget_within<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) {
|
pub fn widget_within<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) -> Size {
|
||||||
self.widget_at(id, region.within(&self.region));
|
self.widget_at(id, region.within(&self.region))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn widget_at<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) {
|
fn widget_at<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) -> Size {
|
||||||
self.children.push(id.id());
|
self.children.push(id.id());
|
||||||
|
// Passed directly rather than looked up from `self.active`: this
|
||||||
|
// widget's own `ActiveData` (which would carry its `move_slot`) is
|
||||||
|
// not inserted there until *after* its own `Widget::draw` returns,
|
||||||
|
// so a lookup here -- for a child drawn partway through that same
|
||||||
|
// call -- would always find nothing. `self.move_slot` is this
|
||||||
|
// widget's own slot, already known, and always correct regardless
|
||||||
|
// of insertion order. See `UiRenderState::move_parent_of`.
|
||||||
self.state.draw_inner(
|
self.state.draw_inner(
|
||||||
self.layer,
|
self.layer,
|
||||||
id.id(),
|
id.id(),
|
||||||
region,
|
region,
|
||||||
Some(self.id),
|
Some(self.id),
|
||||||
|
self.move_slot.idx() as u32,
|
||||||
self.mask,
|
self.mask,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
crate::render::MaskIdx::NONE,
|
||||||
self.rsc,
|
self.rsc,
|
||||||
);
|
);
|
||||||
|
self.state
|
||||||
|
.active
|
||||||
|
.get(&id.id())
|
||||||
|
.map(|a| a.size)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move an already-drawn child from wherever it currently sits to
|
||||||
|
/// `region` (resolved against this widget's own region, matching
|
||||||
|
/// `widget_within`) without a second draw -- an O(1) offset write via
|
||||||
|
/// `UiRenderState::mov`. For a container that draws a child
|
||||||
|
/// provisionally to learn its size (e.g. `Aligned`) and then places it
|
||||||
|
/// for real. Only valid when the target keeps the child's drawn size;
|
||||||
|
/// if the shape actually changes, the normal `widget_within` dispatch
|
||||||
|
/// (which detects that from the stored region) does the right thing
|
||||||
|
/// instead.
|
||||||
|
pub fn reposition<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) {
|
||||||
|
let region = region.within(&self.region);
|
||||||
|
self.state.reposition(id.id(), region, self.rsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw `child` at a provisional region to learn its size under one
|
||||||
|
/// axis's worth of assumption, discard everything it wrote, then draw
|
||||||
|
/// it again at the region that assumption produced. For the rare
|
||||||
|
/// parent that cannot pick an offered size without already knowing the
|
||||||
|
/// answer. Twice the cost of one `draw`; every other case in this file
|
||||||
|
/// avoids it.
|
||||||
|
pub fn draw_twice<W: ?Sized>(
|
||||||
|
&mut self,
|
||||||
|
id: &StrongWidget<W>,
|
||||||
|
first: UiRegion,
|
||||||
|
second: impl FnOnce(Size) -> UiRegion,
|
||||||
|
) -> Size {
|
||||||
|
let used = self.widget_within(id, first);
|
||||||
|
let region = second(used);
|
||||||
|
self.widget_within(id, region)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn texture_within(&mut self, handle: &TextureHandle, region: UiRegion) {
|
pub fn texture_within(&mut self, handle: &TextureHandle, region: UiRegion) {
|
||||||
self.textures.push(handle.clone());
|
self.textures.push(handle.clone());
|
||||||
self.primitive_at(handle.primitive(), region.within(&self.region));
|
self.write_image(handle.image_index(), region.within(&self.region));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn texture(&mut self, handle: &TextureHandle) {
|
pub fn texture(&mut self, handle: &TextureHandle) {
|
||||||
self.textures.push(handle.clone());
|
self.textures.push(handle.clone());
|
||||||
self.primitive(handle.primitive());
|
self.write_image(handle.image_index(), self.region);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn texture_at(&mut self, handle: &TextureHandle, region: UiRegion) {
|
pub fn texture_at(&mut self, handle: &TextureHandle, region: UiRegion) {
|
||||||
self.textures.push(handle.clone());
|
self.textures.push(handle.clone());
|
||||||
self.primitive_at(handle.primitive(), region);
|
self.write_image(handle.image_index(), region);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// returns (handle, offset from top left)
|
/// A standalone image draws with its own bind group rather than sharing
|
||||||
pub fn render_text(&mut self, buffer: &mut TextBuffer, attrs: &TextAttrs) -> RenderedText {
|
/// the layer's one instanced draw, so it goes through
|
||||||
|
/// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`.
|
||||||
|
fn write_image(&mut self, texture_idx: u32, region: UiRegion) {
|
||||||
|
let h = self.state.write_image(
|
||||||
|
self.layer,
|
||||||
|
self.id,
|
||||||
|
texture_idx,
|
||||||
|
region,
|
||||||
|
self.mask,
|
||||||
|
self.move_slot,
|
||||||
|
);
|
||||||
|
if self.mask != MaskIdx::NONE {
|
||||||
|
self.rsc.ui_mut().masks.push_ref(self.mask);
|
||||||
|
}
|
||||||
|
self.own(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_text(
|
||||||
|
&mut self,
|
||||||
|
buffer: &mut TextBuffer,
|
||||||
|
attrs: &TextAttrs,
|
||||||
|
width: Option<f32>,
|
||||||
|
) -> RenderedText {
|
||||||
|
let density = self.state.density;
|
||||||
|
// Counted here rather than in `TextView::render`, which returns
|
||||||
|
// its memoized layout without reaching this -- so this counts
|
||||||
|
// shapes, not requests. `UiRenderState::take_counters`.
|
||||||
|
self.state.shape_count += 1;
|
||||||
let ui = self.rsc.ui_mut();
|
let ui = self.rsc.ui_mut();
|
||||||
ui.text.draw(buffer, attrs, &mut ui.textures)
|
ui.text
|
||||||
|
.render(buffer, attrs, width, &mut ui.textures, density)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which glyph atlas the glyphs handed out right now belong to --
|
||||||
|
/// what a widget caching a [`RenderedText`] across frames has to
|
||||||
|
/// compare against before re-emitting it (`GlyphAtlas::clear`).
|
||||||
|
pub fn atlas_generation(&mut self) -> u64 {
|
||||||
|
self.rsc.ui_mut().text.atlas.generation()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a laid-out string: one quad per glyph, all sampling the atlas.
|
||||||
|
///
|
||||||
|
/// `origin` is where the text's top-left goes; every glyph is placed at an
|
||||||
|
/// absolute pixel offset from it, so re-drawing after a resize is this loop
|
||||||
|
/// and nothing else.
|
||||||
|
pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) {
|
||||||
|
// A caller re-emitting quads placed against an atlas that has since
|
||||||
|
// been cleared draws every glyph from coordinates now holding
|
||||||
|
// something else. Caught at the submission rather than on screen,
|
||||||
|
// where it reads as fragments of unrelated letters. `assert_eq!`
|
||||||
|
// for R1's reason: two integers per laid-out string, not per
|
||||||
|
// glyph, and the failure is unreadable text on a release build.
|
||||||
|
assert_eq!(
|
||||||
|
text.generation,
|
||||||
|
self.atlas_generation(),
|
||||||
|
"glyphs placed against atlas generation {} submitted against {}: the holder did not \
|
||||||
|
re-render after the atlas was cleared",
|
||||||
|
text.generation,
|
||||||
|
self.atlas_generation(),
|
||||||
|
);
|
||||||
|
let flags_for = |is_color| {
|
||||||
|
if is_color {
|
||||||
|
GlyphPrimitive::IS_COLOR
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for glyph in text.glyphs.iter() {
|
||||||
|
let mut region = origin;
|
||||||
|
region.x.end = region.x.start;
|
||||||
|
region.y.end = region.y.start;
|
||||||
|
let mut region = region.offset(UiVec2::abs(glyph.offset));
|
||||||
|
region.x.end = region.x.start + UiScalar::abs(glyph.entry.width as f32);
|
||||||
|
region.y.end = region.y.start + UiScalar::abs(glyph.entry.height as f32);
|
||||||
|
self.primitive_at(
|
||||||
|
GlyphPrimitive::new(
|
||||||
|
glyph.entry.uv_min,
|
||||||
|
glyph.entry.uv_max,
|
||||||
|
glyph.entry.layer,
|
||||||
|
glyph.color,
|
||||||
|
flags_for(glyph.entry.is_color),
|
||||||
|
),
|
||||||
|
region,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn region(&self) -> UiRegion {
|
pub fn region(&self) -> UiRegion {
|
||||||
self.region
|
self.region
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn size<W: ?Sized + Widget>(&mut self, id: &StrongWidget<W>) -> Size {
|
|
||||||
self.size_ctx().size(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn len_axis<W: ?Sized + Widget>(&mut self, id: &StrongWidget<W>, axis: Axis) -> Len {
|
|
||||||
match axis {
|
|
||||||
Axis::X => self.size_ctx().width(id),
|
|
||||||
Axis::Y => self.size_ctx().height(id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn output_size(&self) -> Vec2 {
|
pub fn output_size(&self) -> Vec2 {
|
||||||
self.state.output_size
|
self.state.output_size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Physical pixels per `dp` -- see `UiRenderState::density`'s field
|
||||||
|
/// doc. What `Len::dp`'s `apply_rest` call resolves against.
|
||||||
|
pub fn density(&self) -> f32 {
|
||||||
|
self.state.density
|
||||||
|
}
|
||||||
|
|
||||||
pub fn px_size(&mut self) -> Vec2 {
|
pub fn px_size(&mut self) -> Vec2 {
|
||||||
self.region.size().to_abs(self.state.output_size)
|
self.region.size().to_abs(self.state.output_size)
|
||||||
}
|
}
|
||||||
@@ -138,8 +392,4 @@ impl<'a> Painter<'a> {
|
|||||||
pub fn id(&self) -> &WidgetId {
|
pub fn id(&self) -> &WidgetId {
|
||||||
&self.id
|
&self.id
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn size_ctx(&mut self) -> SizeCtx<'_> {
|
|
||||||
self.state.size_ctx(self.id, self.region.size(), self.rsc)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+909
-68
File diff suppressed because it is too large.
Load diff
@@ -1,86 +0,0 @@
|
|||||||
use crate::{
|
|
||||||
Axis, AxisT, IdLike, Len, RenderedText, Size, TextAttrs, TextBuffer, TextData, Textures,
|
|
||||||
UiVec2, WidgetAxisFns, WidgetId, Widgets, XAxis, YAxis, ui::cache::Cache, util::Vec2,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct SizeCtx<'a> {
|
|
||||||
pub text: &'a mut TextData,
|
|
||||||
pub textures: &'a mut Textures,
|
|
||||||
pub(super) source: WidgetId,
|
|
||||||
pub(super) widgets: &'a Widgets,
|
|
||||||
pub(super) cache: &'a mut Cache,
|
|
||||||
/// TODO: should this be pub? rn used for sized
|
|
||||||
pub outer: UiVec2,
|
|
||||||
pub(super) output_size: Vec2,
|
|
||||||
pub(super) id: WidgetId,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SizeCtx<'_> {
|
|
||||||
pub fn id(&self) -> &WidgetId {
|
|
||||||
&self.id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn source(&self) -> &WidgetId {
|
|
||||||
&self.source
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn len_inner<A: const AxisT>(&mut self, id: WidgetId) -> Len {
|
|
||||||
if let Some((_, len)) = self.cache.size.axis::<A>().get(&id) {
|
|
||||||
return *len;
|
|
||||||
}
|
|
||||||
let len = self
|
|
||||||
.widgets
|
|
||||||
.get_dyn_dynamic(id)
|
|
||||||
.desired_len::<A>(&mut SizeCtx {
|
|
||||||
text: self.text,
|
|
||||||
textures: self.textures,
|
|
||||||
source: self.source,
|
|
||||||
widgets: self.widgets,
|
|
||||||
cache: self.cache,
|
|
||||||
outer: self.outer,
|
|
||||||
output_size: self.output_size,
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
self.cache.size.axis::<A>().insert(id, (self.outer, len));
|
|
||||||
len
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn width(&mut self, id: impl IdLike) -> Len {
|
|
||||||
self.len_inner::<XAxis>(id.id())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn height(&mut self, id: impl IdLike) -> Len {
|
|
||||||
self.len_inner::<YAxis>(id.id())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn len_axis(&mut self, id: impl IdLike, axis: Axis) -> Len {
|
|
||||||
match axis {
|
|
||||||
Axis::X => self.width(id),
|
|
||||||
Axis::Y => self.height(id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn size(&mut self, id: impl IdLike) -> Size {
|
|
||||||
let id = id.id();
|
|
||||||
Size {
|
|
||||||
x: self.width(id),
|
|
||||||
y: self.height(id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn px_size(&mut self) -> Vec2 {
|
|
||||||
self.outer.to_abs(self.output_size)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn output_size(&mut self) -> Vec2 {
|
|
||||||
self.output_size
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn draw_text(&mut self, buffer: &mut TextBuffer, attrs: &TextAttrs) -> RenderedText {
|
|
||||||
self.text.draw(buffer, attrs, self.textures)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn label(&self, id: WidgetId) -> &String {
|
|
||||||
self.widgets.label(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -71,6 +71,15 @@ impl<T, I: IdNum> TrackedArena<T, I> {
|
|||||||
self.refs[i.idx()] += 1;
|
self.refs[i.idx()] += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mutable access to an existing entry, for the rare case (the move
|
||||||
|
/// offset chain) where an already-allocated slot is updated in place
|
||||||
|
/// rather than replaced. Marks the arena changed so the GPU copy is
|
||||||
|
/// re-uploaded.
|
||||||
|
pub fn get_mut(&mut self, id: Id<I>) -> &mut T {
|
||||||
|
self.changed = true;
|
||||||
|
&mut self.inner.data[id.idx()]
|
||||||
|
}
|
||||||
|
|
||||||
pub fn remove(&mut self, id: Id<I>) -> T
|
pub fn remove(&mut self, id: Id<I>) -> T
|
||||||
where
|
where
|
||||||
T: Copy,
|
T: Copy,
|
||||||
|
|||||||
@@ -9,15 +9,16 @@ pub const trait DivOr {
|
|||||||
fn div_or(self, rhs: Self, other: Self) -> Self;
|
fn div_or(self, rhs: Self, other: Self) -> Self;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl const DivOr for f32 {
|
const impl DivOr for f32 {
|
||||||
fn div_or(self, rhs: Self, other: Self) -> Self {
|
fn div_or(self, rhs: Self, other: Self) -> Self {
|
||||||
let res = self / rhs;
|
let res = self / rhs;
|
||||||
if res.is_nan() { other } else { res }
|
if res.is_nan() { other } else { res }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: const Add<Output = T> + const Sub<Output = T> + const Mul<Output = T> + const DivOr + Copy> const
|
const impl<
|
||||||
LerpUtil for T
|
T: const Add<Output = T> + const Sub<Output = T> + const Mul<Output = T> + const DivOr + Copy,
|
||||||
|
> LerpUtil for T
|
||||||
{
|
{
|
||||||
/// linear interpolation
|
/// linear interpolation
|
||||||
/// from * (1.0 - self) + to * self
|
/// from * (1.0 - self) + to * self
|
||||||
@@ -37,7 +38,7 @@ macro_rules! impl_op {
|
|||||||
use super::*;
|
use super::*;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use std::ops::*;
|
use std::ops::*;
|
||||||
impl const $op for $T {
|
const impl $op for $T {
|
||||||
type Output = Self;
|
type Output = Self;
|
||||||
|
|
||||||
fn $fn(self, rhs: Self) -> Self::Output {
|
fn $fn(self, rhs: Self) -> Self::Output {
|
||||||
@@ -46,12 +47,12 @@ macro_rules! impl_op {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl const $opa for $T {
|
const impl $opa for $T {
|
||||||
fn $fna(&mut self, rhs: Self) {
|
fn $fna(&mut self, rhs: Self) {
|
||||||
*self = self.$fn(rhs);
|
*self = self.$fn(rhs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl const $op<f32> for $T {
|
const impl $op<f32> for $T {
|
||||||
type Output = Self;
|
type Output = Self;
|
||||||
|
|
||||||
fn $fn(self, rhs: f32) -> Self::Output {
|
fn $fn(self, rhs: f32) -> Self::Output {
|
||||||
@@ -60,7 +61,7 @@ macro_rules! impl_op {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl const $op<$T> for f32 {
|
const impl $op<$T> for f32 {
|
||||||
type Output = $T;
|
type Output = $T;
|
||||||
|
|
||||||
fn $fn(self, rhs: $T) -> Self::Output {
|
fn $fn(self, rhs: $T) -> Self::Output {
|
||||||
@@ -69,7 +70,7 @@ macro_rules! impl_op {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl const $opa<f32> for $T {
|
const impl $opa<f32> for $T {
|
||||||
fn $fna(&mut self, rhs: f32) {
|
fn $fna(&mut self, rhs: f32) {
|
||||||
*self = self.$fn(rhs);
|
*self = self.$fn(rhs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ pub struct SlotId {
|
|||||||
genr: u32,
|
genr: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SlotId {
|
||||||
|
/// A stable, collision-free `u64` encoding of this id -- for a caller
|
||||||
|
/// (accesskit's `NodeId`, today) that wants a flat integer key rather
|
||||||
|
/// than the two `u32`s. `idx` is offset by one so no real id ever
|
||||||
|
/// encodes to 0, which callers can then reserve for their own
|
||||||
|
/// out-of-band root/window node.
|
||||||
|
pub fn as_u64(&self) -> u64 {
|
||||||
|
((self.idx as u64) + 1) << 32 | self.genr as u64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct SlotVec<T> {
|
pub struct SlotVec<T> {
|
||||||
data: Vec<(u32, Option<T>)>,
|
data: Vec<(u32, Option<T>)>,
|
||||||
free: Vec<u32>,
|
free: Vec<u32>,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ impl_op!(Vec2 Sub sub; x y);
|
|||||||
impl_op!(Vec2 Mul mul; x y);
|
impl_op!(Vec2 Mul mul; x y);
|
||||||
impl_op!(Vec2 Div div; x y);
|
impl_op!(Vec2 Div div; x y);
|
||||||
|
|
||||||
impl const DivOr for Vec2 {
|
const impl DivOr for Vec2 {
|
||||||
fn div_or(self, rhs: Self, other: Self) -> Self {
|
fn div_or(self, rhs: Self, other: Self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
x: self.x.div_or(rhs.x, other.x),
|
x: self.x.div_or(rhs.x, other.x),
|
||||||
|
|||||||
+47
-19
@@ -1,4 +1,4 @@
|
|||||||
use crate::{Axis, AxisT, Len, Painter, SizeCtx};
|
use crate::{Painter, Size};
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
|
|
||||||
mod data;
|
mod data;
|
||||||
@@ -16,31 +16,59 @@ pub use view::*;
|
|||||||
pub use widgets::*;
|
pub use widgets::*;
|
||||||
|
|
||||||
pub trait Widget: Any {
|
pub trait Widget: Any {
|
||||||
fn draw(&mut self, painter: &mut Painter);
|
/// Draw within `painter.region()` (the space the parent offered) and
|
||||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len;
|
/// report how much of it was actually used, per axis.
|
||||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len;
|
fn draw(&mut self, painter: &mut Painter) -> Size;
|
||||||
}
|
|
||||||
|
|
||||||
pub trait WidgetAxisFns {
|
/// True if `draw`'s output (both the primitives it writes and the
|
||||||
fn desired_len<A: AxisT>(&mut self, ctx: &mut SizeCtx) -> Len;
|
/// `Size` it returns) is the same for any `painter.region()` of the
|
||||||
}
|
/// same *content* -- an icon, a fixed-size rect, an already-decoded
|
||||||
|
/// image at its natural size. Default `false` (redraw on any change to
|
||||||
|
/// the offered region) because assuming independence wrongly produces
|
||||||
|
/// a stale draw; a widget must opt in. See LAYOUT.md.
|
||||||
|
fn is_size_independent(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
impl<W: Widget + ?Sized> WidgetAxisFns for W {
|
/// What kind of control this is, for the AccessKit tree `ui::access`
|
||||||
fn desired_len<A: AxisT>(&mut self, ctx: &mut SizeCtx) -> Len {
|
/// builds (RUST.md's I4). Only consulted for a widget that also has an
|
||||||
match A::get() {
|
/// explicit `.label()` -- an unnamed widget is never visited by that
|
||||||
Axis::X => self.desired_width(ctx),
|
/// tree at all, named or not, so the default here costs nothing except
|
||||||
Axis::Y => self.desired_height(ctx),
|
/// at the handful of call sites that opt in. Default `Unknown` (a
|
||||||
}
|
/// generic control with no more specific semantics); a widget with a
|
||||||
|
/// real platform equivalent -- `TextEdit`'s `MultilineTextInput` --
|
||||||
|
/// overrides it.
|
||||||
|
fn access_role(&self) -> accesskit::Role {
|
||||||
|
accesskit::Role::Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance whatever this widget is animating to `now`, and say whether
|
||||||
|
/// it is still animating afterwards. Default: nothing is, so a widget
|
||||||
|
/// opts in by overriding this *and* by something calling
|
||||||
|
/// [`crate::UiData::animate`] with its id when the animation starts --
|
||||||
|
/// which is that animation's path out, since the driver
|
||||||
|
/// ([`crate::UiData::tick_animations`]) drops every id whose `tick`
|
||||||
|
/// answers `false`.
|
||||||
|
///
|
||||||
|
/// Called once per frame, before the frame's draw, by whichever
|
||||||
|
/// backend owns the surface; a `true` answer is what makes that
|
||||||
|
/// backend ask for another frame. So this is the only thing in iris
|
||||||
|
/// that moves without an input event, and a widget that animates
|
||||||
|
/// without registering simply never moves -- which is exactly how a
|
||||||
|
/// finger fling looked on Iris's phone before this existed.
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn tick(&mut self, now: std::time::Instant) -> bool {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for () {
|
impl Widget for () {
|
||||||
fn draw(&mut self, _: &mut Painter) {}
|
fn draw(&mut self, _: &mut Painter) -> Size {
|
||||||
fn desired_width(&mut self, _: &mut SizeCtx) -> Len {
|
Size::ZERO
|
||||||
Len::ZERO
|
|
||||||
}
|
}
|
||||||
fn desired_height(&mut self, _: &mut SizeCtx) -> Len {
|
|
||||||
Len::ZERO
|
fn is_size_independent(&self) -> bool {
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ pub struct Widgets {
|
|||||||
send: Sender<WidgetId>,
|
send: Sender<WidgetId>,
|
||||||
recv: Receiver<WidgetId>,
|
recv: Receiver<WidgetId>,
|
||||||
pub(crate) waiting: HashSet<WidgetId>,
|
pub(crate) waiting: HashSet<WidgetId>,
|
||||||
|
/// Every widget that has ever been given an explicit `.label()` --
|
||||||
|
/// `ui::access::AccessTree` walks exactly this set, not the whole
|
||||||
|
/// arena, so a widget nobody named costs it nothing. Symmetric with
|
||||||
|
/// `free_next` below, which is this set's one removal path.
|
||||||
|
named: HashSet<WidgetId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Widgets {
|
impl Widgets {
|
||||||
@@ -20,6 +25,7 @@ impl Widgets {
|
|||||||
needs_redraw: Default::default(),
|
needs_redraw: Default::default(),
|
||||||
vec: Default::default(),
|
vec: Default::default(),
|
||||||
waiting: Default::default(),
|
waiting: Default::default(),
|
||||||
|
named: Default::default(),
|
||||||
send,
|
send,
|
||||||
recv,
|
recv,
|
||||||
}
|
}
|
||||||
@@ -95,9 +101,20 @@ impl Widgets {
|
|||||||
&self.data(id.id()).unwrap().label
|
&self.data(id.id()).unwrap().label
|
||||||
}
|
}
|
||||||
|
|
||||||
/// useful for debugging
|
/// Also the one place a widget opts into `ui::access`'s AccessKit tree
|
||||||
|
/// (RUST.md's I4) -- see `named`'s doc comment.
|
||||||
pub fn set_label(&mut self, id: impl IdLike, label: String) {
|
pub fn set_label(&mut self, id: impl IdLike, label: String) {
|
||||||
self.data_mut(id.id()).unwrap().label = label;
|
let id = id.id();
|
||||||
|
self.data_mut(id).unwrap().label = label;
|
||||||
|
self.named.insert(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every widget with an explicit name, for `ui::access::AccessTree` to
|
||||||
|
/// walk. Order is unspecified; `AccessTree` doesn't need one; a screen
|
||||||
|
/// reader's own traversal is worked out by uiautomator from each
|
||||||
|
/// node's on-screen bounds instead.
|
||||||
|
pub fn named(&self) -> impl Iterator<Item = WidgetId> + '_ {
|
||||||
|
self.named.iter().copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn data_mut(&mut self, id: impl IdLike) -> Option<&mut WidgetData> {
|
pub fn data_mut(&mut self, id: impl IdLike) -> Option<&mut WidgetData> {
|
||||||
@@ -107,6 +124,7 @@ impl Widgets {
|
|||||||
pub fn free_next(&mut self) -> Option<WidgetId> {
|
pub fn free_next(&mut self) -> Option<WidgetId> {
|
||||||
let next = self.recv.try_recv().ok()?;
|
let next = self.recv.try_recv().ok()?;
|
||||||
self.vec.free(next);
|
self.vec.free(next);
|
||||||
|
self.named.remove(&next);
|
||||||
Some(next)
|
Some(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
//! (d) of IRIS_TODO.md's "Benchmarks" item: 1,000 image rows, checking that
|
||||||
|
//! standalone-image bind-group *creation* -- a real `wgpu` resource, unlike
|
||||||
|
//! the counters in `benches/message_lazy_span.rs` -- goes to zero once every
|
||||||
|
//! image has loaded. This needs an actual `wgpu` device (`GpuTextures`,
|
||||||
|
//! `UiRenderNode`), so unlike the rest of the suite it cannot run as a
|
||||||
|
//! plain binary; run it through `iris/run-headless.sh bench_images`, which
|
||||||
|
//! gives it a real (headless, GPU-accelerated) compositor and surface. See
|
||||||
|
//! `run-bench.sh` for the wrapper that greps its output into one line.
|
||||||
|
//!
|
||||||
|
//! Each `RedrawRequested` prints the frame number and
|
||||||
|
//! `UiRenderNode::take_image_bind_group_creates()` for that frame, then
|
||||||
|
//! requests another redraw (nothing else marks the scene dirty, so without
|
||||||
|
//! this the app would only ever draw once). The first frame is expected to
|
||||||
|
//! report 1,000 (one create per image, on first load); the steady state
|
||||||
|
//! IRIS_TODO.md asks this scenario to prove is every frame after settling
|
||||||
|
//! down to 0.
|
||||||
|
//!
|
||||||
|
//! After `SETTLE_FRAMES` it appends one *new* image row (a transcript
|
||||||
|
//! receiving one more message) and keeps counting -- a chat transcript's
|
||||||
|
//! real access pattern is "one more image arrives," not "reload the whole
|
||||||
|
//! list," so the steady-state question that actually matters is the
|
||||||
|
//! *incremental* cost of that one append, not just whether an untouched
|
||||||
|
//! scene costs zero. It exits after `FRAMES`.
|
||||||
|
|
||||||
|
use iris::prelude::*;
|
||||||
|
|
||||||
|
const ROWS: usize = 1000;
|
||||||
|
const SETTLE_FRAMES: usize = 4;
|
||||||
|
const FRAMES: usize = 6;
|
||||||
|
|
||||||
|
#[derive(DefaultUiState)]
|
||||||
|
struct State {
|
||||||
|
ui_state: DefaultUiState,
|
||||||
|
span: WeakWidget<Span>,
|
||||||
|
frame: usize,
|
||||||
|
appended: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DefaultAppState for State {
|
||||||
|
fn new(
|
||||||
|
mut ui_state: DefaultUiState,
|
||||||
|
rsc: &mut DefaultRsc<Self>,
|
||||||
|
_: Proxy<Self::Event>,
|
||||||
|
) -> Self {
|
||||||
|
let mut span = Span::empty(Dir::DOWN);
|
||||||
|
for _ in 0..ROWS {
|
||||||
|
let img = image::DynamicImage::new_rgba8(32, 32);
|
||||||
|
let widget = image::<DefaultRsc<Self>>(img)(rsc);
|
||||||
|
let widget = rsc.ui.widgets.add_strong(widget);
|
||||||
|
span.push(widget.any());
|
||||||
|
}
|
||||||
|
let span = rsc.ui.widgets.add_strong(span);
|
||||||
|
let span_weak = span.weak();
|
||||||
|
let root = rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.add_strong(ScrollArea::new(span.any(), Axis::Y, Pin::End));
|
||||||
|
ui_state.set_root(root.any());
|
||||||
|
Self {
|
||||||
|
ui_state,
|
||||||
|
span: span_weak,
|
||||||
|
frame: 0,
|
||||||
|
appended: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn window_event(
|
||||||
|
&mut self,
|
||||||
|
event: winit::event::WindowEvent,
|
||||||
|
rsc: &mut DefaultRsc<Self>,
|
||||||
|
_render: &mut UiRenderState,
|
||||||
|
) {
|
||||||
|
if !matches!(event, winit::event::WindowEvent::RedrawRequested) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.frame += 1;
|
||||||
|
let creates = self.ui_state.renderer.ui.take_image_bind_group_creates();
|
||||||
|
println!(
|
||||||
|
"BENCH_IMAGES frame={} bind_group_creates={creates}",
|
||||||
|
self.frame
|
||||||
|
);
|
||||||
|
if self.frame == SETTLE_FRAMES && !self.appended {
|
||||||
|
self.appended = true;
|
||||||
|
let img = image::DynamicImage::new_rgba8(32, 32);
|
||||||
|
let widget = image::<DefaultRsc<Self>>(img)(rsc);
|
||||||
|
let widget = rsc.ui.widgets.add_strong(widget);
|
||||||
|
rsc.ui
|
||||||
|
.widgets
|
||||||
|
.get_mut(&self.span)
|
||||||
|
.unwrap()
|
||||||
|
.push(widget.any());
|
||||||
|
println!("BENCH_IMAGES appended one image after settling");
|
||||||
|
}
|
||||||
|
if self.frame < FRAMES {
|
||||||
|
self.ui_state.window.request_redraw();
|
||||||
|
} else {
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
DefaultApp::<State>::run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
//! RUST.md's I3: `iris::widget::LazySpan` with 800 rows of varied-length
|
||||||
|
//! wrapped text, one in twelve carrying a small image, scrollable with the
|
||||||
|
//! mouse wheel. Run headless with `iris/run-headless.sh message_list --shot
|
||||||
|
//! /tmp/message_list.png` -- there is no display on this machine, so that
|
||||||
|
//! is the only way to see it rendered; `run-tests.sh`/`cargo test` never
|
||||||
|
//! touch this file.
|
||||||
|
//!
|
||||||
|
//! Rows alternate two background tints so a screenshot can show the
|
||||||
|
//! boundary between adjacent rows even where the text itself wraps to a
|
||||||
|
//! different number of lines -- exactly the "variable-height rows" I3
|
||||||
|
//! asks for, and the thing a virtualised list gets wrong first if it is
|
||||||
|
//! wrong at all (a gap, an overlap, a row the wrong colour). This example
|
||||||
|
//! is also what found `LazySpan::place`'s oversized-background bug (see
|
||||||
|
//! lazy_span.rs's module doc and its `a_fill_shaped_background_is_not_left_
|
||||||
|
//! oversized` test) -- a plain unit test could have (and now does) catch
|
||||||
|
//! it directly, but it was this screenshot rendering as a single blank
|
||||||
|
//! tinted rectangle that pointed at it first.
|
||||||
|
|
||||||
|
use iris::prelude::*;
|
||||||
|
use winit::{dpi::LogicalSize, window::WindowAttributes};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
DefaultApp::<State>::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DefaultUiState)]
|
||||||
|
struct State {
|
||||||
|
ui_state: DefaultUiState,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROWS: usize = 800;
|
||||||
|
const IMAGE_EVERY: usize = 12;
|
||||||
|
|
||||||
|
/// Repeats a short sentence a varying number of times per row so real
|
||||||
|
/// wrapping happens at every row height from one line to several, rather
|
||||||
|
/// than every row being identically tall (which would render correctly
|
||||||
|
/// even with a broken height measurement).
|
||||||
|
fn row_text(i: usize) -> String {
|
||||||
|
const SENTENCE: &str =
|
||||||
|
"Iris lays out this row once and moves it on scroll, never re-laying it out. ";
|
||||||
|
let repeats = 1 + (i * 7) % 5;
|
||||||
|
format!("Message {i}: {}", SENTENCE.repeat(repeats))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small solid-colour square standing in for a real decoded image --
|
||||||
|
/// what matters for I3 is that a row can carry an `Image` widget at all,
|
||||||
|
/// not what the picture shows.
|
||||||
|
fn row_image(i: usize) -> image::DynamicImage {
|
||||||
|
let hue = ((i * 47) % 255) as u8;
|
||||||
|
image::RgbaImage::from_pixel(48, 48, image::Rgba([hue, 128, 255 - hue, 255])).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_row<Rsc: UiRsc + 'static>(rsc: &mut Rsc, i: usize) -> StrongWidget {
|
||||||
|
let tint = if i.is_multiple_of(2) {
|
||||||
|
Color::rgb(120, 130, 170)
|
||||||
|
} else {
|
||||||
|
Color::rgb(70, 80, 140)
|
||||||
|
};
|
||||||
|
let text_color = Color::BLACK;
|
||||||
|
if i.is_multiple_of(IMAGE_EVERY) {
|
||||||
|
let text = wtext(row_text(i))
|
||||||
|
.wrap(true)
|
||||||
|
.color(text_color)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
let img = image::<Rsc>(row_image(i))(rsc);
|
||||||
|
let img = rsc.widgets_mut().add_strong(img).any();
|
||||||
|
let mut span = Span::empty(Dir::DOWN);
|
||||||
|
span.push(text);
|
||||||
|
span.push(img);
|
||||||
|
span.pad(dp(8.0))
|
||||||
|
.background(rect(tint))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
} else {
|
||||||
|
wtext(row_text(i))
|
||||||
|
.wrap(true)
|
||||||
|
.color(text_color)
|
||||||
|
.pad(dp(8.0))
|
||||||
|
.background(rect(tint))
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DefaultAppState for State {
|
||||||
|
// A phone-plausible portrait shape (the transcript screen this is
|
||||||
|
// standing in for). The tiling headless compositor `run-headless.sh`
|
||||||
|
// uses ignores this and fills its own 1920x1200 output regardless, but
|
||||||
|
// it's a correct hint for any other backend (a real window manager, or
|
||||||
|
// android-view) and costs nothing to state.
|
||||||
|
fn window_attributes() -> WindowAttributes {
|
||||||
|
WindowAttributes::default().with_inner_size(LogicalSize::new(420.0, 900.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(
|
||||||
|
mut ui_state: DefaultUiState,
|
||||||
|
rsc: &mut DefaultRsc<Self>,
|
||||||
|
_: Proxy<Self::Event>,
|
||||||
|
) -> Self {
|
||||||
|
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
|
||||||
|
for i in 0..ROWS {
|
||||||
|
let row = build_row(rsc, i);
|
||||||
|
list.push_back(LazyItem::new(i as u64, row));
|
||||||
|
}
|
||||||
|
|
||||||
|
// `.scrollable()`, like anything else that scrolls -- here the
|
||||||
|
// span's own inherent one, which registers the wheel and the drag
|
||||||
|
// against the controller it already owns rather than wrapping it
|
||||||
|
// in a `ScrollArea`. Masked outside it, since a `LazySpan` draws
|
||||||
|
// the row straddling each edge in full and asserts something clips
|
||||||
|
// it.
|
||||||
|
let root = list
|
||||||
|
.scrollable()
|
||||||
|
.masked()
|
||||||
|
.background(rect(Color::WHITE))
|
||||||
|
.add_strong(rsc);
|
||||||
|
ui_state.set_root(root.any());
|
||||||
|
|
||||||
|
Self { ui_state }
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-188
@@ -1,14 +1,14 @@
|
|||||||
use cosmic_text::Family;
|
|
||||||
use std::{cell::RefCell, rc::Rc};
|
|
||||||
use winit::event::WindowEvent;
|
|
||||||
|
|
||||||
use iris::prelude::*;
|
use iris::prelude::*;
|
||||||
type ClientRsc = DefaultRsc<Client>;
|
use winit::event::WindowEvent;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
DefaultApp::<Client>::run();
|
DefaultApp::<Client>::run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The tabs example: five demo panes plus a message composer, built by
|
||||||
|
/// `tabs_ui::build` and driven here through the winit backend. The same
|
||||||
|
/// widget tree also runs on the android-view backend, through
|
||||||
|
/// `iris-android-app` -- see RUST.md's I2.
|
||||||
#[derive(DefaultUiState)]
|
#[derive(DefaultUiState)]
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
ui_state: DefaultUiState,
|
ui_state: DefaultUiState,
|
||||||
@@ -21,189 +21,11 @@ impl DefaultAppState for Client {
|
|||||||
rsc: &mut DefaultRsc<Self>,
|
rsc: &mut DefaultRsc<Self>,
|
||||||
_: Proxy<Self::Event>,
|
_: Proxy<Self::Event>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let rrect = rect(Color::WHITE).radius(20);
|
let widgets = tabs_ui::build(rsc, &mut ui_state);
|
||||||
let pad_test = (
|
Self {
|
||||||
rrect.color(Color::BLUE),
|
ui_state,
|
||||||
(
|
info: widgets.info,
|
||||||
rrect
|
}
|
||||||
.color(Color::RED)
|
|
||||||
.sized((100, 100))
|
|
||||||
.center()
|
|
||||||
.width(rest(2)),
|
|
||||||
(
|
|
||||||
rrect.color(Color::ORANGE),
|
|
||||||
rrect.color(Color::LIME).pad(10.0),
|
|
||||||
)
|
|
||||||
.span(Dir::RIGHT)
|
|
||||||
.width(rest(2)),
|
|
||||||
rrect.color(Color::YELLOW),
|
|
||||||
)
|
|
||||||
.span(Dir::RIGHT)
|
|
||||||
.pad(10)
|
|
||||||
.width(rest(3)),
|
|
||||||
)
|
|
||||||
.span(Dir::RIGHT)
|
|
||||||
.add(rsc);
|
|
||||||
|
|
||||||
let span_test = (
|
|
||||||
rrect.color(Color::GREEN).width(100),
|
|
||||||
rrect.color(Color::ORANGE),
|
|
||||||
rrect.color(Color::CYAN),
|
|
||||||
rrect.color(Color::BLUE).width(rel(0.5)),
|
|
||||||
rrect.color(Color::MAGENTA).width(100),
|
|
||||||
rrect.color(Color::RED).width(100),
|
|
||||||
)
|
|
||||||
.span(Dir::LEFT)
|
|
||||||
.add(rsc);
|
|
||||||
|
|
||||||
let span_add = Span::empty(Dir::RIGHT).add(rsc);
|
|
||||||
|
|
||||||
let add_button = rect(Color::LIME)
|
|
||||||
.radius(30)
|
|
||||||
.on(CursorSense::click(), move |_, rsc| {
|
|
||||||
let child = image(include_bytes!("assets/sungals.png"))
|
|
||||||
.center()
|
|
||||||
.add_strong(rsc);
|
|
||||||
span_add(rsc).push(child);
|
|
||||||
})
|
|
||||||
.sized((150, 150))
|
|
||||||
.align(Align::BOT_RIGHT);
|
|
||||||
|
|
||||||
let del_button = rect(Color::RED)
|
|
||||||
.radius(30)
|
|
||||||
.on(CursorSense::click(), move |_, rsc| {
|
|
||||||
span_add(rsc).pop();
|
|
||||||
})
|
|
||||||
.sized((150, 150))
|
|
||||||
.align(Align::BOT_LEFT);
|
|
||||||
|
|
||||||
let span_add_test = (span_add, add_button, del_button).stack().add(rsc);
|
|
||||||
|
|
||||||
let btext = |content| wtext(content).size(30);
|
|
||||||
|
|
||||||
let text_test = (
|
|
||||||
btext("this is a").align(Align::LEFT),
|
|
||||||
btext("teeeeeeeest").align(Align::RIGHT),
|
|
||||||
btext("okkk\nokkkkkk!").align(Align::LEFT),
|
|
||||||
btext("hmm"),
|
|
||||||
btext("a"),
|
|
||||||
(
|
|
||||||
btext("'").family(Family::Monospace).align(Align::TOP),
|
|
||||||
btext("'").family(Family::Monospace),
|
|
||||||
btext(":gamer mode").family(Family::Monospace),
|
|
||||||
rect(Color::CYAN).sized((10, 10)).center(),
|
|
||||||
rect(Color::RED).sized((100, 100)).center(),
|
|
||||||
rect(Color::PURPLE).sized((50, 50)).align(Align::TOP),
|
|
||||||
)
|
|
||||||
.span(Dir::RIGHT)
|
|
||||||
.center(),
|
|
||||||
wtext("pretty cool right?").size(50),
|
|
||||||
)
|
|
||||||
.span(Dir::DOWN)
|
|
||||||
.add(rsc);
|
|
||||||
|
|
||||||
let texts = Span::empty(Dir::DOWN).gap(10).add(rsc);
|
|
||||||
let msg_area = texts.scrollable().masked().background(rect(Color::SKY));
|
|
||||||
let add_text = wtext("add")
|
|
||||||
.editable(EditMode::MultiLine)
|
|
||||||
.text_align(Align::LEFT)
|
|
||||||
.size(30)
|
|
||||||
.attr::<Selectable>(())
|
|
||||||
.on(Submit, move |ctx, rsc| {
|
|
||||||
let w = ctx.widget;
|
|
||||||
let content = w.edit(rsc).take();
|
|
||||||
let text = wtext(content)
|
|
||||||
.editable(EditMode::MultiLine)
|
|
||||||
.size(30)
|
|
||||||
.text_align(Align::LEFT)
|
|
||||||
.wrap(true)
|
|
||||||
.attr::<Selectable>(());
|
|
||||||
let msg_box = text
|
|
||||||
.background(rect(Color::WHITE.darker(0.5)))
|
|
||||||
.add_strong(rsc);
|
|
||||||
texts(rsc).push(msg_box);
|
|
||||||
})
|
|
||||||
.add(rsc);
|
|
||||||
|
|
||||||
let text_edit_scroll = (
|
|
||||||
msg_area.height(rest(1)),
|
|
||||||
(
|
|
||||||
Rect::new(Color::WHITE.darker(0.9)),
|
|
||||||
(
|
|
||||||
add_text.width(rest(1)),
|
|
||||||
Rect::new(Color::GREEN)
|
|
||||||
.on(CursorSense::click(), move |ctx, rsc: &mut ClientRsc| {
|
|
||||||
rsc.run_event::<Submit>(add_text, (), ctx.state);
|
|
||||||
})
|
|
||||||
.sized((40, 40)),
|
|
||||||
)
|
|
||||||
.span(Dir::RIGHT)
|
|
||||||
.pad(10),
|
|
||||||
)
|
|
||||||
.stack()
|
|
||||||
.size(StackSize::Child(1))
|
|
||||||
.layer_offset(1)
|
|
||||||
.align(Align::BOT),
|
|
||||||
)
|
|
||||||
.span(Dir::DOWN)
|
|
||||||
.add(rsc);
|
|
||||||
|
|
||||||
let main = WidgetPtr::new().add(rsc);
|
|
||||||
|
|
||||||
let vals = Rc::new(RefCell::new((0, Vec::new())));
|
|
||||||
let mut switch_button = |color, to: WeakWidget, label| {
|
|
||||||
let to = to.upgrade(rsc);
|
|
||||||
let vec = &mut vals.borrow_mut().1;
|
|
||||||
let i = vec.len();
|
|
||||||
if vec.is_empty() {
|
|
||||||
vec.push(None);
|
|
||||||
main(rsc).set(to);
|
|
||||||
} else {
|
|
||||||
vec.push(Some(to));
|
|
||||||
}
|
|
||||||
let vals = vals.clone();
|
|
||||||
let rect = rect(color)
|
|
||||||
.on(CursorSense::click(), move |ctx, rsc| {
|
|
||||||
let (prev, vec) = &mut *vals.borrow_mut();
|
|
||||||
if let Some(h) = vec[i].take() {
|
|
||||||
vec[*prev] = main(rsc).replace(h);
|
|
||||||
*prev = i;
|
|
||||||
}
|
|
||||||
ctx.widget(rsc).color = color.darker(0.3);
|
|
||||||
})
|
|
||||||
.on(
|
|
||||||
CursorSense::HoverStart | CursorSense::unclick(),
|
|
||||||
move |ctx, rsc| {
|
|
||||||
ctx.widget(rsc).color = color.brighter(0.2);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.on(CursorSense::HoverEnd, move |ctx, rsc| {
|
|
||||||
ctx.widget(rsc).color = color;
|
|
||||||
});
|
|
||||||
(rect, wtext(label).size(30).text_align(Align::CENTER)).stack()
|
|
||||||
};
|
|
||||||
|
|
||||||
let tabs = (
|
|
||||||
switch_button(Color::RED, pad_test, "pad"),
|
|
||||||
switch_button(Color::GREEN, span_test, "span"),
|
|
||||||
switch_button(Color::BLUE, span_add_test, "image span"),
|
|
||||||
switch_button(Color::MAGENTA, text_test, "text layout"),
|
|
||||||
switch_button(
|
|
||||||
Color::YELLOW.mul_rgb(0.5),
|
|
||||||
text_edit_scroll,
|
|
||||||
"text edit scroll",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.span(Dir::RIGHT);
|
|
||||||
|
|
||||||
let info = wtext("").add(rsc);
|
|
||||||
let info_sect = info.pad(10).align(Align::RIGHT);
|
|
||||||
|
|
||||||
((tabs.height(40), main.pad(10)).span(Dir::DOWN), info_sect)
|
|
||||||
.stack()
|
|
||||||
.set_root(rsc, &mut ui_state);
|
|
||||||
|
|
||||||
Self { ui_state, info }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn window_event(
|
fn window_event(
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# The compositor `run-headless.sh` starts, because this machine has no
|
||||||
|
# display. Nothing here is meant to be looked at directly; `grim` is.
|
||||||
|
#
|
||||||
|
# No Xwayland: winit talks Wayland natively, and starting an X server is a
|
||||||
|
# second thing to go wrong for no gain. (`emu`'s config forces it because the
|
||||||
|
# Android emulator's renderer speaks GLX.)
|
||||||
|
xwayland disable
|
||||||
|
|
||||||
|
# A desktop-shaped output, since this is the desktop half of the port. Larger
|
||||||
|
# than the window an example opens, so nothing is scaled or clipped.
|
||||||
|
output HEADLESS-1 mode 1920x1200@60Hz
|
||||||
|
|
||||||
|
default_border none
|
||||||
|
focus_follows_mouse no
|
||||||
+3
-3
@@ -4,9 +4,9 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
proc-macro2 = "1.0.103"
|
proc-macro2 = "1.0.107"
|
||||||
quote = "1.0.42"
|
quote = "1.0.47"
|
||||||
syn = { version = "2.0.111", features = ["full"] }
|
syn = { version = "3.0.5", features = ["full"] }
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
proc-macro = true
|
proc-macro = true
|
||||||
+15
-3
@@ -18,6 +18,12 @@ struct Input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct InputFn {
|
struct InputFn {
|
||||||
|
/// Everything written above the `fn` -- in practice a `///` doc
|
||||||
|
/// comment, which is why this exists: `masked_by` and its siblings
|
||||||
|
/// are public API and rustdoc is where their contract is read, so a
|
||||||
|
/// macro that silently rejected `///` sent the explanation into an
|
||||||
|
/// ordinary `//` comment nobody generating docs ever sees.
|
||||||
|
attrs: Vec<Attribute>,
|
||||||
sig: Signature,
|
sig: Signature,
|
||||||
body: Block,
|
body: Block,
|
||||||
}
|
}
|
||||||
@@ -32,9 +38,10 @@ impl Parse for Input {
|
|||||||
input.parse::<Token![;]>()?;
|
input.parse::<Token![;]>()?;
|
||||||
let mut fns = Vec::new();
|
let mut fns = Vec::new();
|
||||||
while !input.is_empty() {
|
while !input.is_empty() {
|
||||||
|
let attrs = input.call(Attribute::parse_outer)?;
|
||||||
let sig = input.parse()?;
|
let sig = input.parse()?;
|
||||||
let body = input.parse()?;
|
let body = input.parse()?;
|
||||||
fns.push(InputFn { sig, body })
|
fns.push(InputFn { attrs, sig, body })
|
||||||
}
|
}
|
||||||
if !input.is_empty() {
|
if !input.is_empty() {
|
||||||
input.error("function expected");
|
input.error("function expected");
|
||||||
@@ -59,10 +66,15 @@ pub fn widget_trait(input: TokenStream) -> TokenStream {
|
|||||||
fns,
|
fns,
|
||||||
} = parse_macro_input!(input as Input);
|
} = parse_macro_input!(input as Input);
|
||||||
|
|
||||||
let sigs: Vec<_> = fns.iter().map(|f| f.sig.clone()).collect();
|
// The attributes go on the trait's own signature, which is the one
|
||||||
|
// rustdoc renders; the impl gets the bare `fn`.
|
||||||
|
let sigs: Vec<_> = fns
|
||||||
|
.iter()
|
||||||
|
.map(|InputFn { attrs, sig, .. }| quote! { #(#attrs)* #sig })
|
||||||
|
.collect();
|
||||||
let impls: Vec<_> = fns
|
let impls: Vec<_> = fns
|
||||||
.iter()
|
.iter()
|
||||||
.map(|InputFn { sig, body }| quote! { #sig #body })
|
.map(|InputFn { sig, body, .. }| quote! { #sig #body })
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let Some(GenericParam::Type(state)) = generics.params.first() else {
|
let Some(GenericParam::Type(state)) = generics.params.first() else {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[package]
|
||||||
|
name = "rig-input"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
# Layer 2's input half (docs/RUST.md's "Three test layers"): replays one
|
||||||
|
# of the `.touch` files the headless tests use into whatever window is
|
||||||
|
# under a Wayland compositor, so the *same recording* drives the
|
||||||
|
# assertion layer and the layer a person looks at.
|
||||||
|
#
|
||||||
|
# It exists because this machine's compositor has no pointer to move.
|
||||||
|
# `run-headless.sh` starts sway on the headless backend with no input
|
||||||
|
# devices at all (`WLR_LIBINPUT_NO_DEVICES=1`, `LIBSEAT_BACKEND=noop`),
|
||||||
|
# so `swaymsg seat - cursor press` reports success and nothing reaches
|
||||||
|
# the client -- `swaymsg -t get_seats` shows `capabilities: 0`. wlroots
|
||||||
|
# 0.19 dropped `WLR_HEADLESS_INPUTS`, and ydotool's uinput device would
|
||||||
|
# be ignored by a compositor that is not reading libinput. The
|
||||||
|
# virtual-pointer protocol is what is left, and it is a client protocol,
|
||||||
|
# so it needs no devices and no root.
|
||||||
|
|
||||||
|
# Named for what it does rather than for the crate, since the crate may
|
||||||
|
# grow a keyboard replay beside it.
|
||||||
|
[[bin]]
|
||||||
|
name = "replay-touch"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# `TouchScript` -- the same parser the harness uses, so a file that
|
||||||
|
# replays here and one that replays headless can never disagree.
|
||||||
|
iris = { path = ".." }
|
||||||
|
wayland-client = "0.31.15"
|
||||||
|
wayland-protocols-wlr = { version = "0.3.12", features = ["client"] }
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
//! Replays a `.touch` file into the compositor as a left-button drag --
|
||||||
|
//! see this crate's `Cargo.toml` for why it exists rather than
|
||||||
|
//! `swaymsg seat - cursor`.
|
||||||
|
//!
|
||||||
|
//! WAYLAND_DISPLAY=… replay-touch WIDTH HEIGHT FILE
|
||||||
|
//!
|
||||||
|
//! `WIDTH`/`HEIGHT` are the output's own size, because the virtual
|
||||||
|
//! pointer protocol positions absolutely against an extent rather than
|
||||||
|
//! in pixels; passing the output size makes a script's coordinates mean
|
||||||
|
//! the same pixels they mean in the headless tests.
|
||||||
|
//!
|
||||||
|
//! Replayed in real time (the sleeps between samples are the gaps in the
|
||||||
|
//! file), because winit has no timestamp on a pointer event and dates
|
||||||
|
//! each one when it arrives -- so a 20ms flick has to actually take
|
||||||
|
//! 20ms here, unlike layer 1 where the sample carries its own time.
|
||||||
|
|
||||||
|
use iris::harness::{TouchAction, TouchScript};
|
||||||
|
use std::time::Duration;
|
||||||
|
use wayland_client::protocol::wl_pointer::ButtonState;
|
||||||
|
use wayland_client::protocol::{wl_registry, wl_seat};
|
||||||
|
use wayland_client::{Connection, Dispatch, QueueHandle, delegate_noop};
|
||||||
|
use wayland_protocols_wlr::virtual_pointer::v1::client::{
|
||||||
|
zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1,
|
||||||
|
zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `linux/input-event-codes.h`. The protocol takes the kernel's own
|
||||||
|
/// button code, not a wayland enum.
|
||||||
|
const BTN_LEFT: u32 = 0x110;
|
||||||
|
|
||||||
|
/// How long the pointer sits at the gesture's first position before the
|
||||||
|
/// script starts -- see the comment at the pre-step in `main`.
|
||||||
|
const SETTLE: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Globals {
|
||||||
|
seat: Option<wl_seat::WlSeat>,
|
||||||
|
manager: Option<ZwlrVirtualPointerManagerV1>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<wl_registry::WlRegistry, ()> for Globals {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
registry: &wl_registry::WlRegistry,
|
||||||
|
event: wl_registry::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
qh: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
let wl_registry::Event::Global {
|
||||||
|
name,
|
||||||
|
interface,
|
||||||
|
version,
|
||||||
|
} = event
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match interface.as_str() {
|
||||||
|
"wl_seat" => {
|
||||||
|
state.seat = Some(registry.bind(name, version.min(7), qh, ()));
|
||||||
|
}
|
||||||
|
"zwlr_virtual_pointer_manager_v1" => {
|
||||||
|
state.manager = Some(registry.bind(name, version.min(2), qh, ()));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate_noop!(Globals: ignore wl_seat::WlSeat);
|
||||||
|
delegate_noop!(Globals: ZwlrVirtualPointerManagerV1);
|
||||||
|
delegate_noop!(Globals: ZwlrVirtualPointerV1);
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let [width, height, path] = args.as_slice() else {
|
||||||
|
eprintln!("usage: replay-touch WIDTH HEIGHT FILE");
|
||||||
|
std::process::exit(2);
|
||||||
|
};
|
||||||
|
let (width, height) = (parse(width, "WIDTH"), parse(height, "HEIGHT"));
|
||||||
|
let text = std::fs::read_to_string(path)
|
||||||
|
.unwrap_or_else(|e| fail(&format!("could not read {path}: {e}")));
|
||||||
|
let script = TouchScript::parse(&text).unwrap_or_else(|e| fail(&e));
|
||||||
|
|
||||||
|
let conn = Connection::connect_to_env().unwrap_or_else(|e| {
|
||||||
|
fail(&format!(
|
||||||
|
"no wayland display ({e}); is WAYLAND_DISPLAY set?"
|
||||||
|
))
|
||||||
|
});
|
||||||
|
let mut queue = conn.new_event_queue();
|
||||||
|
let qh = queue.handle();
|
||||||
|
let display = conn.display();
|
||||||
|
display.get_registry(&qh, ());
|
||||||
|
let mut globals = Globals::default();
|
||||||
|
queue
|
||||||
|
.roundtrip(&mut globals)
|
||||||
|
.unwrap_or_else(|e| fail(&format!("wayland roundtrip failed: {e}")));
|
||||||
|
|
||||||
|
let manager = globals.manager.as_ref().unwrap_or_else(|| {
|
||||||
|
fail(
|
||||||
|
"this compositor does not offer zwlr_virtual_pointer_manager_v1, so a pointer cannot \
|
||||||
|
be synthesised; sway and every wlroots compositor do",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let pointer = manager.create_virtual_pointer(globals.seat.as_ref(), &qh, ());
|
||||||
|
|
||||||
|
// Put the pointer where the gesture starts and let the compositor
|
||||||
|
// settle before anything is pressed. Without this the press is
|
||||||
|
// dropped: sway has just learned about this pointer, and a button
|
||||||
|
// sent in the same breath as the motion that first puts it over a
|
||||||
|
// window arrives before there is a focused surface to send it to --
|
||||||
|
// winit sees `CursorEntered`, the moves and the *release*, never the
|
||||||
|
// press, so the gesture reads as a hover and nothing scrolls. Found
|
||||||
|
// by printing winit's own events; the settle is what fixed it.
|
||||||
|
if let Some(first) = script.samples.first() {
|
||||||
|
pointer.motion_absolute(0, first.pos.x as u32, first.pos.y as u32, width, height);
|
||||||
|
pointer.frame();
|
||||||
|
conn.flush()
|
||||||
|
.unwrap_or_else(|e| fail(&format!("flush: {e}")));
|
||||||
|
std::thread::sleep(SETTLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut previous = 0;
|
||||||
|
for sample in &script.samples {
|
||||||
|
std::thread::sleep(Duration::from_millis(sample.t_ms - previous));
|
||||||
|
previous = sample.t_ms;
|
||||||
|
let t = sample.t_ms as u32;
|
||||||
|
pointer.motion_absolute(t, sample.pos.x as u32, sample.pos.y as u32, width, height);
|
||||||
|
// One frame per sample, so the compositor delivers them as
|
||||||
|
// separate pointer frames rather than coalescing the whole
|
||||||
|
// gesture -- the shape the file recorded is the point.
|
||||||
|
pointer.frame();
|
||||||
|
// The button goes in a frame of its own, *after* the motion has
|
||||||
|
// been committed. Sent in the same frame as the motion that
|
||||||
|
// first puts the pointer over the window, sway drops it: the
|
||||||
|
// client sees `CursorEntered` and the moves but never a
|
||||||
|
// `MouseInput { state: Pressed }`, so the whole gesture reads as
|
||||||
|
// a hover and nothing scrolls. Found exactly that way, by
|
||||||
|
// printing winit's events.
|
||||||
|
let state = match sample.action {
|
||||||
|
TouchAction::Down => Some(ButtonState::Pressed),
|
||||||
|
TouchAction::Up | TouchAction::Cancel => Some(ButtonState::Released),
|
||||||
|
TouchAction::Move => None,
|
||||||
|
};
|
||||||
|
if let Some(state) = state {
|
||||||
|
pointer.button(t, BTN_LEFT, state);
|
||||||
|
pointer.frame();
|
||||||
|
}
|
||||||
|
conn.flush()
|
||||||
|
.unwrap_or_else(|e| fail(&format!("flush: {e}")));
|
||||||
|
}
|
||||||
|
pointer.destroy();
|
||||||
|
conn.flush().ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(text: &str, what: &str) -> u32 {
|
||||||
|
text.parse()
|
||||||
|
.unwrap_or_else(|_| fail(&format!("{what} is not a whole number: {text:?}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail(message: &str) -> ! {
|
||||||
|
eprintln!("replay-touch: {message}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Runs iris's on-demand benchmark suite (IRIS_TODO.md's "Benchmarks" item).
|
||||||
|
# Never run by `cargo test`; run this by hand or before/after a layout
|
||||||
|
# change. Always release -- see AGENTS.md's own rule against reading a
|
||||||
|
# frame time from a debug build.
|
||||||
|
#
|
||||||
|
# ./run-bench.sh # everything
|
||||||
|
# ./run-bench.sh list # just the CPU-only message-list scenarios
|
||||||
|
# ./run-bench.sh images # just the GPU bind-group-creation scenario
|
||||||
|
set -eu
|
||||||
|
here=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
cd "$here"
|
||||||
|
|
||||||
|
what="${1:-all}"
|
||||||
|
|
||||||
|
if [ "$what" = "all" ] || [ "$what" = "list" ]; then
|
||||||
|
echo "=== message_list (CPU-only, no window) ==="
|
||||||
|
cargo bench --bench message_list
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$what" = "all" ] || [ "$what" = "images" ]; then
|
||||||
|
echo "=== bench_images (real wgpu device, via run-headless.sh) ==="
|
||||||
|
timeout 60 ./run-headless.sh bench_images --seconds 4 2>&1 | grep "^BENCH_IMAGES"
|
||||||
|
fi
|
||||||
Executable
+200
@@ -0,0 +1,200 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Run an iris example on this machine, which has no display.
|
||||||
|
#
|
||||||
|
# ./run-headless.sh tabs [-- cargo args]
|
||||||
|
# ./run-headless.sh tabs --shot /tmp/tabs.png --seconds 4
|
||||||
|
# ./run-headless.sh phone --phone --dir ../app-rust --shot /tmp/p.png
|
||||||
|
# ./run-headless.sh phone --phone --dir ../app-rust \
|
||||||
|
# --replay ../app-rust/touch/flick-120hz.touch --shot /tmp/p.png
|
||||||
|
#
|
||||||
|
# `--dir DIR` names the workspace to build in, defaulting to `iris/` (this
|
||||||
|
# script's own directory). The app's examples -- the phone-sized transcript
|
||||||
|
# screen and everything else that is about *this product* -- live in
|
||||||
|
# `app-rust/`, which is a workspace of its own; `replay-touch` is still
|
||||||
|
# built from iris, since it is part of the rig rather than of either app.
|
||||||
|
#
|
||||||
|
# `--phone` is layer 2 of docs/RUST.md's "Three test layers": the output
|
||||||
|
# and the window take Iris's phone's own size and density (1080x2424 at
|
||||||
|
# `content_scale` 2.55, from docs/bench/iris-phone-v2-2026-09-06.md,
|
||||||
|
# carried in `ai_app::ui::fixture::PHONE_*`), and `IRIS_SCALE` hands that
|
||||||
|
# density to iris the way `DisplayMetrics.density` does on Android
|
||||||
|
# (`iris::default::content_scale`). So a screenshot from here and one
|
||||||
|
# from the phone are the same layout at the same density, and what
|
||||||
|
# differs is only the renderer. Without it the output stays desktop-
|
||||||
|
# shaped, which is what every other example wants.
|
||||||
|
#
|
||||||
|
# `--replay FILE` drives one of the `.touch` recordings the headless
|
||||||
|
# tests use (`app-rust/touch/`) into the window through
|
||||||
|
# `rig-input`'s `replay-touch` -- one recording, both layers. With
|
||||||
|
# `--shot` it also writes `<shot>-before.png` from just before the
|
||||||
|
# gesture, since "the list moved" is a claim about two pictures.
|
||||||
|
#
|
||||||
|
# `--bin` runs a real crate binary instead of an example (E4's
|
||||||
|
# `ai-app-desktop`, which is a window a person runs, not a demo) --
|
||||||
|
# `cargo build --bin NAME` instead of `--example NAME`, and
|
||||||
|
# `target/debug/NAME` instead of `target/debug/examples/NAME`. Its own
|
||||||
|
# argv (the CLI flags a real binary takes, as opposed to `cargo build`'s
|
||||||
|
# own flags after `--`) comes through `$RUN_HEADLESS_ARGS`, word-split on
|
||||||
|
# purpose -- an example never needed one, so there was nowhere to plumb it
|
||||||
|
# through positionally without disturbing the existing `-- cargo args`
|
||||||
|
# convention above.
|
||||||
|
#
|
||||||
|
# The VM has a real GPU and no display (the `this-machine-graphics` skill
|
||||||
|
# says what it is and how it fails), so what is missing here is only a
|
||||||
|
# compositor to give winit a surface. So: a headless sway, the same trick
|
||||||
|
# `emu` uses for the Android emulator, and `grim` to see the result.
|
||||||
|
#
|
||||||
|
# It is deliberately *not* `emu`'s compositor. sway tiles, so adding a window
|
||||||
|
# to the one an emulator is sitting in resizes that emulator's window, and a
|
||||||
|
# peer session's `emu up` could join at any moment. This one has its own
|
||||||
|
# socket and its own runtime directory and goes away with the machine.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
here=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
# The workspace `--dir` selects; see the header. `$here` is iris itself.
|
||||||
|
workdir="$here"
|
||||||
|
run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless"
|
||||||
|
seconds=3
|
||||||
|
shot=""
|
||||||
|
replay=""
|
||||||
|
example=""
|
||||||
|
kind=example
|
||||||
|
phone=no
|
||||||
|
|
||||||
|
# The phone Iris runs the bench on. Not typed from memory: these are
|
||||||
|
# `ai_app::ui::fixture::PHONE_WIDTH`/`PHONE_HEIGHT`/`PHONE_SCALE`, which
|
||||||
|
# in turn come from her own reports -- keep the three in step.
|
||||||
|
PHONE_MODE=1080x2424@120Hz
|
||||||
|
PHONE_SCALE=2.55
|
||||||
|
DESKTOP_MODE=1920x1200@60Hz
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--shot) shot=$2; shift 2 ;;
|
||||||
|
--seconds) seconds=$2; shift 2 ;;
|
||||||
|
--bin) kind=bin; shift ;;
|
||||||
|
--phone) phone=yes; shift ;;
|
||||||
|
--replay) replay=$2; shift 2 ;;
|
||||||
|
--dir) workdir=$(cd "$2" && pwd); shift 2 ;;
|
||||||
|
--) shift; break ;;
|
||||||
|
*) example=$1; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--phone] [--dir DIR] [--replay TOUCH] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; }
|
||||||
|
[ -z "$replay" ] || [ -f "$replay" ] || { echo "run-headless: no touch script at $replay" >&2; exit 2; }
|
||||||
|
|
||||||
|
mkdir -p "$run"
|
||||||
|
export SWAYSOCK="$run/sway.sock"
|
||||||
|
|
||||||
|
# Named rather than left to sway's pid-based default, so a second run reuses
|
||||||
|
# this compositor instead of starting another beside it.
|
||||||
|
if ! swaymsg -t get_version >/dev/null 2>&1; then
|
||||||
|
rm -f "$SWAYSOCK"
|
||||||
|
WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 LIBSEAT_BACKEND=noop \
|
||||||
|
setsid sway -c "$here/headless.conf" >"$run/sway.log" 2>&1 &
|
||||||
|
i=0
|
||||||
|
while [ $i -lt 20 ]; do
|
||||||
|
swaymsg -t get_version >/dev/null 2>&1 && break
|
||||||
|
i=$((i + 1)); sleep 0.5
|
||||||
|
done
|
||||||
|
swaymsg -t get_version >/dev/null 2>&1 || {
|
||||||
|
echo "run-headless: compositor did not start; see $run/sway.log" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Asked of the compositor rather than guessed: sway takes the first free
|
||||||
|
# wayland-N, and this machine may already have one.
|
||||||
|
rm -f "$run/display"
|
||||||
|
swaymsg exec -- "sh -c 'printf %s \"\$WAYLAND_DISPLAY\" > $run/display'" >/dev/null
|
||||||
|
i=0
|
||||||
|
while [ $i -lt 20 ]; do
|
||||||
|
[ -s "$run/display" ] && break
|
||||||
|
i=$((i + 1)); sleep 0.5
|
||||||
|
done
|
||||||
|
[ -s "$run/display" ] || { echo "run-headless: could not read WAYLAND_DISPLAY" >&2; exit 1; }
|
||||||
|
WAYLAND_DISPLAY=$(cat "$run/display")
|
||||||
|
export WAYLAND_DISPLAY
|
||||||
|
|
||||||
|
echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2
|
||||||
|
|
||||||
|
# Set every run rather than only when it changes: this compositor is
|
||||||
|
# reused across runs (see the socket comment above), so a desktop-shaped
|
||||||
|
# run after a phone-shaped one would otherwise inherit the phone's output
|
||||||
|
# and silently screenshot the wrong size.
|
||||||
|
if [ "$phone" = yes ]; then
|
||||||
|
mode=$PHONE_MODE
|
||||||
|
export IRIS_SCALE="$PHONE_SCALE"
|
||||||
|
echo "run-headless: phone-shaped output $PHONE_MODE at IRIS_SCALE=$PHONE_SCALE" >&2
|
||||||
|
else
|
||||||
|
mode=$DESKTOP_MODE
|
||||||
|
fi
|
||||||
|
swaymsg output HEADLESS-1 mode "$mode" >/dev/null
|
||||||
|
# The extent `replay-touch` positions against, so a script's coordinates
|
||||||
|
# are the output's own pixels.
|
||||||
|
out_w=${mode%x*}
|
||||||
|
out_h=${mode#*x}; out_h=${out_h%@*}
|
||||||
|
|
||||||
|
# Built before the app starts, so a compile error is not reported as a
|
||||||
|
# window that failed to move.
|
||||||
|
[ -z "$replay" ] || (cd "$here" && cargo build --bin replay-touch -p rig-input) >&2
|
||||||
|
|
||||||
|
cd "$workdir"
|
||||||
|
if [ "$kind" = bin ]; then
|
||||||
|
cargo build --bin "$example" "$@" >&2
|
||||||
|
bin="$workdir/target/debug/$example"
|
||||||
|
else
|
||||||
|
cargo build --example "$example" "$@" >&2
|
||||||
|
bin="$workdir/target/debug/examples/$example"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC2086 -- deliberately word-split: this is the
|
||||||
|
# binary's own argv, not a single path.
|
||||||
|
"$bin" ${RUN_HEADLESS_ARGS:-} >"$run/$example.log" 2>&1 &
|
||||||
|
pid=$!
|
||||||
|
trap 'kill "$pid" 2>/dev/null || true' EXIT INT TERM
|
||||||
|
|
||||||
|
# Wait for the window to be mapped rather than for a number of seconds. A
|
||||||
|
# fixed sleep took an all-black screenshot the first time this ran, when sway
|
||||||
|
# had started in the same invocation and had not composited its output yet --
|
||||||
|
# which is indistinguishable from an app that draws nothing.
|
||||||
|
i=0
|
||||||
|
while [ $i -lt 40 ]; do
|
||||||
|
kill -0 "$pid" 2>/dev/null || break
|
||||||
|
swaymsg -t get_tree --raw 2>/dev/null | grep -q "\"pid\":$pid," && break
|
||||||
|
i=$((i + 1)); sleep 0.25
|
||||||
|
done
|
||||||
|
|
||||||
|
# Then settle, for whatever the example does after its first frame.
|
||||||
|
i=0
|
||||||
|
while [ $i -lt "$((seconds * 2))" ]; do
|
||||||
|
kill -0 "$pid" 2>/dev/null || break
|
||||||
|
i=$((i + 1)); sleep 0.5
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$replay" ] && kill -0 "$pid" 2>/dev/null; then
|
||||||
|
if [ -n "$shot" ]; then
|
||||||
|
grim "${shot%.png}-before.png"
|
||||||
|
echo "run-headless: wrote ${shot%.png}-before.png (before the gesture)" >&2
|
||||||
|
fi
|
||||||
|
"$here/target/debug/replay-touch" "$out_w" "$out_h" "$replay"
|
||||||
|
# A fling outlives the finger: the gesture's own last sample is not
|
||||||
|
# when the list stops. Long enough for Android's spline to settle
|
||||||
|
# (`FlingCalculator::duration` tops out around a second and a half).
|
||||||
|
sleep 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
[ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
wait "$pid" 2>/dev/null || true
|
||||||
|
status=0
|
||||||
|
else
|
||||||
|
wait "$pid" 2>/dev/null || status=$?
|
||||||
|
echo "run-headless: $example exited early (status ${status:-0})" >&2
|
||||||
|
status=${status:-1}
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- $example output ---" >&2
|
||||||
|
cat "$run/$example.log" >&2
|
||||||
|
exit "$status"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# iris needs nightly (see the #![feature] list in core/src/lib.rs and src/lib.rs).
|
||||||
|
# The pin is dated rather than "nightly" because the const-traits feature set
|
||||||
|
# changes shape between nightlies: on 2026-09-04 the vendored January tree would
|
||||||
|
# not parse at all, because `impl const Trait for T` had become
|
||||||
|
# `const impl Trait for T`. A rolling channel turns that into a build that
|
||||||
|
# breaks unattended on whatever machine Dev Updater happens to build on.
|
||||||
|
# Advance this deliberately, with the feature list in RUST.md's I0b.
|
||||||
|
[toolchain]
|
||||||
|
channel = "nightly-2026-09-03"
|
||||||
|
components = ["clippy", "rustfmt"]
|
||||||
|
targets = ["aarch64-linux-android", "x86_64-linux-android"]
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
//! Pass conditions for RUST.md's I4, exercised the same way
|
||||||
|
//! `layout_tests.rs` exercises LAYOUT.md's: `AccessTree` only touches
|
||||||
|
//! `Widgets`/`UiRenderState`, neither of which needs a GPU or a window, so
|
||||||
|
//! it can be driven directly against `layout_tests::TestRsc`.
|
||||||
|
|
||||||
|
use crate::layout_tests::TestRsc;
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_named_widget_reaches_the_tree_with_its_role_and_bounds() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let leaf: WeakWidget<Rect> = rect(UiColor::WHITE).label("Add task").add(&mut rsc);
|
||||||
|
let root = leaf.upgrade(&mut rsc).any();
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((800.0, 600.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
let mut access = AccessTree::new();
|
||||||
|
let update = access
|
||||||
|
.update(rsc.widgets(), &render, &rsc)
|
||||||
|
.expect("a first draw with a named widget must produce a tree");
|
||||||
|
|
||||||
|
// One node for the widget, one for the synthetic window root.
|
||||||
|
assert_eq!(update.nodes.len(), 2);
|
||||||
|
let (_, node) = update
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.find(|(_, n)| n.role() != accesskit::Role::Window)
|
||||||
|
.expect("the named widget's own node");
|
||||||
|
assert_eq!(node.label(), Some("Add task"));
|
||||||
|
assert_eq!(node.role(), accesskit::Role::Unknown);
|
||||||
|
let bounds = node.bounds().expect("a drawn widget reports its bounds");
|
||||||
|
let region = render
|
||||||
|
.window_region(&leaf, &rsc)
|
||||||
|
.expect("the widget is active after render.update");
|
||||||
|
assert_eq!(bounds.x0, region.top_left.x as f64);
|
||||||
|
assert_eq!(bounds.y0, region.top_left.y as f64);
|
||||||
|
assert_eq!(bounds.x1, region.bot_right.x as f64);
|
||||||
|
assert_eq!(bounds.y1, region.bot_right.y as f64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_widget_with_no_label_never_reaches_the_tree() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let root = rsc.ui.widgets.add_strong(rect(UiColor::WHITE));
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((800.0, 600.0));
|
||||||
|
render.update(&root.any(), &mut rsc);
|
||||||
|
|
||||||
|
let mut access = AccessTree::new();
|
||||||
|
assert!(
|
||||||
|
access.update(rsc.widgets(), &render, &rsc).is_none(),
|
||||||
|
"no widget was ever `.label()`ed, so there is nothing to report -- \
|
||||||
|
not even an empty tree change"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LAYOUT.md's "a moved subtree" lesson applies here too: `resolved_region`
|
||||||
|
/// (which `window_region` sits on) walks the move-offset chain, so a
|
||||||
|
/// widget moved via `Offset` -- not redrawn from scratch -- must still
|
||||||
|
/// report where it actually ended up.
|
||||||
|
#[test]
|
||||||
|
fn bounds_follow_a_moved_widget_and_updates_stay_incremental() {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let leaf: WeakWidget<Rect> = rect(UiColor::WHITE).label("thing").add(&mut rsc);
|
||||||
|
let leaf_strong = leaf.upgrade(&mut rsc).any();
|
||||||
|
let offset = rsc.ui.widgets.add_strong(Offset {
|
||||||
|
inner: leaf_strong,
|
||||||
|
amt: UiVec2::ZERO,
|
||||||
|
});
|
||||||
|
let offset_id = offset.weak();
|
||||||
|
let root = offset.any();
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((800.0, 600.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
let mut access = AccessTree::new();
|
||||||
|
access
|
||||||
|
.update(rsc.widgets(), &render, &rsc)
|
||||||
|
.expect("the first draw is always a change");
|
||||||
|
assert_eq!(access.take_rebuilds(), 1);
|
||||||
|
|
||||||
|
// Unchanged frame: nothing moved, nothing renamed -- `update` must
|
||||||
|
// report no change, and the rebuild counter (I4's twin of
|
||||||
|
// `take_counters`) must stay at 0.
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
assert!(access.update(rsc.widgets(), &render, &rsc).is_none());
|
||||||
|
assert_eq!(access.take_rebuilds(), 0);
|
||||||
|
|
||||||
|
// Move the child via `Offset` (a move-offset write, not necessarily a
|
||||||
|
// full redraw of the leaf -- see `resolve_move_chain`) and confirm the
|
||||||
|
// reported bounds shifted by exactly that amount, in exactly one more
|
||||||
|
// rebuild.
|
||||||
|
let before = render
|
||||||
|
.window_region(&leaf, &rsc)
|
||||||
|
.expect("active before the move");
|
||||||
|
rsc.ui.widgets.get_mut(&offset_id).unwrap().amt = UiVec2::abs(Vec2::new(50.0, 0.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
let update = access
|
||||||
|
.update(rsc.widgets(), &render, &rsc)
|
||||||
|
.expect("a moved named widget is a change");
|
||||||
|
assert_eq!(access.take_rebuilds(), 1);
|
||||||
|
|
||||||
|
let after = render
|
||||||
|
.window_region(&leaf, &rsc)
|
||||||
|
.expect("still active after the move");
|
||||||
|
// Not asserting the exact delta: `Offset`'s own `amt` -> pixel mapping
|
||||||
|
// is that widget's business, not this tree's. What I4 owns is that
|
||||||
|
// `AccessTree` reports whatever `window_region` says *now* -- so the
|
||||||
|
// node must have moved, and in the direction the offset moved it.
|
||||||
|
assert!(
|
||||||
|
after.top_left.x > before.top_left.x,
|
||||||
|
"the leaf's reported bounds must move right along with its offset"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, node) = update
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.find(|(_, n)| n.role() != accesskit::Role::Window)
|
||||||
|
.unwrap();
|
||||||
|
let bounds = node.bounds().unwrap();
|
||||||
|
assert_eq!(bounds.x0, after.top_left.x as f64);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
//! I4 (RUST.md): the Android half of the AccessKit push, over
|
||||||
|
//! `accesskit_android::Adapter` and android-view's
|
||||||
|
//! `AccessibilityNodeProvider`. Carries E1's mitigation for the adapter's
|
||||||
|
//! reproducible abort: `accesskit_android`'s `State` (0.4.0 and 0.8.0
|
||||||
|
//! alike) never moves back to `Inactive` once a client attaches, so once
|
||||||
|
//! one has, every later `QueuedEvents::raise` reaches
|
||||||
|
//! `AccessibilityManager.sendAccessibilityEvent` -- which throws if
|
||||||
|
//! accessibility has since been switched off (or the client detached),
|
||||||
|
//! and android-view's `panic = "abort"` turns that Java exception into a
|
||||||
|
//! process kill. `raise_if_enabled` is the gate: ask
|
||||||
|
//! `AccessibilityManager.isEnabled()` immediately before every `raise`
|
||||||
|
//! and drop the events instead of calling it when the answer is no. See
|
||||||
|
//! RUST.md's E1 box for the full repro.
|
||||||
|
use accesskit::{ActionHandler, ActionRequest, ActivationHandler, TreeUpdate};
|
||||||
|
use accesskit_android::QueuedEvents;
|
||||||
|
use android_view::{
|
||||||
|
View,
|
||||||
|
jni::{JNIEnv, objects::JObject},
|
||||||
|
};
|
||||||
|
use iris_core::{AccessTree, UiRenderState, UiRsc, Widgets};
|
||||||
|
|
||||||
|
/// The `ActivationHandler` `accesskit_android::Adapter` asks for its
|
||||||
|
/// initial tree from -- unlike `accesskit_winit`'s handlers (see
|
||||||
|
/// `default/access.rs`), this one is only ever invoked synchronously from
|
||||||
|
/// inside a JNI callback that already holds everything it needs, so it can
|
||||||
|
/// just borrow `IrisViewPeer`'s own fields for the length of one call
|
||||||
|
/// rather than going through a channel.
|
||||||
|
pub(super) struct AndroidAccessSource<'a> {
|
||||||
|
pub widgets: &'a Widgets,
|
||||||
|
pub render: &'a UiRenderState,
|
||||||
|
pub rsc: &'a dyn UiRsc,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActivationHandler for AndroidAccessSource<'_> {
|
||||||
|
fn request_initial_tree(&mut self) -> Option<TreeUpdate> {
|
||||||
|
Some(AccessTree::build_full(self.widgets, self.render, self.rsc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every AccessKit action request is inert here -- see this module's doc
|
||||||
|
/// comment and `default/access.rs`'s matching handler for why: a screen
|
||||||
|
/// reader's tap on a named node is a real touch delivered at that node's
|
||||||
|
/// bounds, which the ordinary pointer path already handles once the
|
||||||
|
/// bounds `AccessTree` reports are right.
|
||||||
|
pub(super) struct NullActionHandler;
|
||||||
|
impl ActionHandler for NullActionHandler {
|
||||||
|
fn do_action(&mut self, _request: ActionRequest) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_accessibility_enabled<'local>(env: &mut JNIEnv<'local>, view: &View<'local>) -> bool {
|
||||||
|
let context = view.context(env);
|
||||||
|
let name = env.new_string("accessibility").unwrap();
|
||||||
|
let manager: JObject = env
|
||||||
|
.call_method(
|
||||||
|
&context.0,
|
||||||
|
"getSystemService",
|
||||||
|
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||||
|
&[(&name).into()],
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.l()
|
||||||
|
.unwrap();
|
||||||
|
if manager.is_null() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
env.call_method(&manager, "isEnabled", "()Z", &[])
|
||||||
|
.unwrap()
|
||||||
|
.z()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one place `QueuedEvents::raise` may be called -- see this module's
|
||||||
|
/// doc comment. Every call site pushes this as a deferred callback rather
|
||||||
|
/// than calling it inline, matching android-view's own demo: `raise`
|
||||||
|
/// itself asks not to be called while the caller holds locks a framework
|
||||||
|
/// callback might, and a deferred callback runs after the current one has
|
||||||
|
/// returned them.
|
||||||
|
pub(super) fn raise_if_enabled<'local>(
|
||||||
|
env: &mut JNIEnv<'local>,
|
||||||
|
view: &View<'local>,
|
||||||
|
events: QueuedEvents,
|
||||||
|
) {
|
||||||
|
if is_accessibility_enabled(env, view) {
|
||||||
|
events.raise(env, &view.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
use crate::attr::{FocusHost, recent_click};
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
use super::view::HasAndroidUiState;
|
||||||
|
|
||||||
|
impl<T: HasAndroidUiState> FocusHost for T {
|
||||||
|
fn recent_click(&mut self) -> bool {
|
||||||
|
recent_click(&mut self.android_state_mut().last_click)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
|
||||||
|
self.android_state_mut().focus = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||||
|
self.android_state().focus == Some(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn focus_gained(&mut self, region: Option<PixelRegion>) {
|
||||||
|
// Showing the keyboard is a JNI call (`InputMethodManager.showSoftInput`),
|
||||||
|
// and this runs deep inside the platform-agnostic sensor dispatch
|
||||||
|
// with no `CallbackCtx` in reach -- `IrisViewPeer::after_input`
|
||||||
|
// (`view.rs`) is what actually makes the call, right after the
|
||||||
|
// sensor pass that got here returns.
|
||||||
|
if region.is_some() {
|
||||||
|
self.android_state_mut().pending_show_keyboard = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
//! `InputConnection`, implemented directly against a focused `TextEdit`
|
||||||
|
//! rather than against a stand-in editor the way android-view's own demo
|
||||||
|
//! does over its `parley::PlainEditor` -- I1 already put parley behind
|
||||||
|
//! `TextEdit`, so this is that same bridge, just wired to iris's widget
|
||||||
|
//! instead of a bespoke one. Follows `demo/src/lib.rs`'s
|
||||||
|
//! `impl InputConnection for DemoViewPeer`, which is where RUST.md's E1
|
||||||
|
//! found the shape this needs (`text_before_cursor` is what gets Gboard's
|
||||||
|
//! suggestion strip to read real words out of the buffer).
|
||||||
|
//!
|
||||||
|
//! Two things the demo tracks that this does not, both noted rather than
|
||||||
|
//! silently dropped: a real "composing region" distinct from the
|
||||||
|
//! selection (`set_composing_region` here just moves the caret, since
|
||||||
|
//! `TextEdit` has no third range to hold one), and batch-edit coalescing
|
||||||
|
//! (`begin`/`end_batch_edit` are no-ops -- a redraw mid-batch costs a frame
|
||||||
|
//! it does not need to, not correctness).
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
use android_view::{
|
||||||
|
CAP_MODE_SENTENCES, CallbackCtx, EditorInfo, IME_FLAG_NO_ENTER_ACTION, IME_FLAG_NO_EXTRACT_UI,
|
||||||
|
IME_FLAG_NO_FULLSCREEN, INPUT_TYPE_CLASS_TEXT, INPUT_TYPE_TEXT_FLAG_AUTO_CORRECT,
|
||||||
|
INPUT_TYPE_TEXT_FLAG_CAP_SENTENCES, INPUT_TYPE_TEXT_FLAG_MULTI_LINE, InputConnection,
|
||||||
|
caps_mode,
|
||||||
|
};
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
use super::view::{AndroidAppState, IrisViewPeer};
|
||||||
|
|
||||||
|
/// Byte offset -> UTF-16 code unit offset, the unit every `InputConnection`
|
||||||
|
/// method speaks in (Java strings are UTF-16). `TextEdit` is byte-indexed
|
||||||
|
/// throughout since I1 moved it to parley -- see `edit.rs`'s doc comment on
|
||||||
|
/// `text()` -- so every crossing of this boundary goes through here rather
|
||||||
|
/// than through ad hoc counting at each call site.
|
||||||
|
fn byte_to_utf16(text: &str, byte_idx: usize) -> usize {
|
||||||
|
text[..byte_idx].encode_utf16().count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn utf16_to_byte(text: &str, utf16_idx: usize) -> usize {
|
||||||
|
let mut utf16_len = 0;
|
||||||
|
for (byte_idx, ch) in text.char_indices() {
|
||||||
|
if utf16_len >= utf16_idx {
|
||||||
|
return byte_idx;
|
||||||
|
}
|
||||||
|
utf16_len += ch.len_utf16();
|
||||||
|
}
|
||||||
|
text.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||||
|
fn focus(&self) -> Option<WeakWidget<TextEdit>> {
|
||||||
|
self.state.android_state().focus
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell Gboard where the caret/selection and the composing region
|
||||||
|
/// actually are, via `InputMethodManager.updateSelection` -- every one
|
||||||
|
/// of android-view's own demo's `set_composing_text_internal`/`render`
|
||||||
|
/// calls this, and this bridge never did, which is what left Gboard's
|
||||||
|
/// own model of the field diverging from `TextEdit`'s real one after
|
||||||
|
/// the very first edit (RUST.md's P0 box, "doesn't enter it until I
|
||||||
|
/// hit space, and also doesn't move cursor forward" -- Gboard holds
|
||||||
|
/// its composing keystrokes back until it believes the app has caught
|
||||||
|
/// up, and without this call it never does). Called from
|
||||||
|
/// [`IrisViewPeer::after_input`], the one tail every touch/key/IME
|
||||||
|
/// callback already runs through, rather than duplicated at each of
|
||||||
|
/// this file's mutating methods.
|
||||||
|
///
|
||||||
|
/// `candidates_start`/`candidates_end` report the composing region;
|
||||||
|
/// `-1, -1` when nothing is composing, matching `EditorInfo`'s own
|
||||||
|
/// convention. `compose_len` is tracked in `char`s (this module's doc
|
||||||
|
/// comment), so this reports it as that many UTF-16 units back from the
|
||||||
|
/// caret -- exact for the common BMP case, the same approximation
|
||||||
|
/// `set_composing_text` already makes.
|
||||||
|
pub(super) fn update_ime_selection(&mut self, ctx: &mut CallbackCtx) {
|
||||||
|
let Some(focus) = self.focus() else { return };
|
||||||
|
let text = &self.rsc[focus];
|
||||||
|
let Some(sel) = text.selection_range() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let content = text.text();
|
||||||
|
let sel_start = byte_to_utf16(content, sel.start) as i32;
|
||||||
|
let sel_end = byte_to_utf16(content, sel.end) as i32;
|
||||||
|
let compose_len = self.state.android_state().compose_len;
|
||||||
|
let (comp_start, comp_end) = if compose_len > 0 {
|
||||||
|
let caret = byte_to_utf16(content, text.caret().unwrap_or(sel.end)) as i32;
|
||||||
|
(caret - compose_len as i32, caret)
|
||||||
|
} else {
|
||||||
|
(-1, -1)
|
||||||
|
};
|
||||||
|
let imm = ctx.view.input_method_manager(&mut ctx.env);
|
||||||
|
imm.update_selection(
|
||||||
|
&mut ctx.env,
|
||||||
|
&ctx.view,
|
||||||
|
sel_start,
|
||||||
|
sel_end,
|
||||||
|
comp_start,
|
||||||
|
comp_end,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<State: AndroidAppState> InputConnection for IrisViewPeer<State> {
|
||||||
|
fn on_create_input_connection<'local>(
|
||||||
|
&mut self,
|
||||||
|
ctx: &mut CallbackCtx<'local>,
|
||||||
|
out_attrs: &EditorInfo<'local>,
|
||||||
|
) {
|
||||||
|
// Set once per `InputConnection`, not per field -- Android calls
|
||||||
|
// this when the view (not a particular widget) attaches to an
|
||||||
|
// IME. `MULTI_LINE`/`AUTO_CORRECT`/`CAP_SENTENCES` cover both the
|
||||||
|
// tabs example's composer and a plain single-line field well
|
||||||
|
// enough that no per-field variant is worth the extra state yet.
|
||||||
|
out_attrs.set_input_type(
|
||||||
|
&mut ctx.env,
|
||||||
|
INPUT_TYPE_CLASS_TEXT
|
||||||
|
| INPUT_TYPE_TEXT_FLAG_CAP_SENTENCES
|
||||||
|
| INPUT_TYPE_TEXT_FLAG_AUTO_CORRECT
|
||||||
|
| INPUT_TYPE_TEXT_FLAG_MULTI_LINE,
|
||||||
|
);
|
||||||
|
out_attrs.set_ime_options(
|
||||||
|
&mut ctx.env,
|
||||||
|
IME_FLAG_NO_FULLSCREEN | IME_FLAG_NO_EXTRACT_UI | IME_FLAG_NO_ENTER_ACTION,
|
||||||
|
);
|
||||||
|
if let Some(focus) = self.focus() {
|
||||||
|
let text = &self.rsc[focus];
|
||||||
|
let sel = text.selection_range().unwrap_or(0..0);
|
||||||
|
let start = byte_to_utf16(text.text(), sel.start) as i32;
|
||||||
|
let end = byte_to_utf16(text.text(), sel.end) as i32;
|
||||||
|
out_attrs.set_initial_sel_start(&mut ctx.env, start);
|
||||||
|
out_attrs.set_initial_sel_end(&mut ctx.env, end);
|
||||||
|
let caps = caps_mode(
|
||||||
|
&mut ctx.env,
|
||||||
|
text.text(),
|
||||||
|
start as usize,
|
||||||
|
CAP_MODE_SENTENCES,
|
||||||
|
);
|
||||||
|
out_attrs.set_initial_caps_mode(&mut ctx.env, caps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_before_cursor<'slf>(
|
||||||
|
&'slf mut self,
|
||||||
|
_ctx: &mut CallbackCtx,
|
||||||
|
n: i32,
|
||||||
|
) -> Option<Cow<'slf, str>> {
|
||||||
|
if n < 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let focus = self.focus()?;
|
||||||
|
let text = &self.rsc[focus];
|
||||||
|
let sel = text.selection_range()?;
|
||||||
|
let end_16 = byte_to_utf16(text.text(), sel.start);
|
||||||
|
let start_16 = end_16.saturating_sub(n as usize);
|
||||||
|
let start = utf16_to_byte(text.text(), start_16);
|
||||||
|
Some(Cow::Borrowed(&text.text()[start..sel.start]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_after_cursor<'slf>(
|
||||||
|
&'slf mut self,
|
||||||
|
_ctx: &mut CallbackCtx,
|
||||||
|
n: i32,
|
||||||
|
) -> Option<Cow<'slf, str>> {
|
||||||
|
if n < 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let focus = self.focus()?;
|
||||||
|
let text = &self.rsc[focus];
|
||||||
|
let sel = text.selection_range()?;
|
||||||
|
let len_16 = byte_to_utf16(text.text(), text.text().len());
|
||||||
|
let start_16 = byte_to_utf16(text.text(), sel.end);
|
||||||
|
let end_16 = (start_16 + n as usize).min(len_16);
|
||||||
|
let end = utf16_to_byte(text.text(), end_16);
|
||||||
|
Some(Cow::Borrowed(&text.text()[sel.end..end]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_text<'slf>(&'slf mut self, _ctx: &mut CallbackCtx) -> Option<Cow<'slf, str>> {
|
||||||
|
let focus = self.focus()?;
|
||||||
|
Some(Cow::Owned(self.rsc[focus].selected_text()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cursor_caps_mode(&mut self, ctx: &mut CallbackCtx, req_modes: u32) -> u32 {
|
||||||
|
let Some(focus) = self.focus() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let text = &self.rsc[focus];
|
||||||
|
let Some(caret) = text.caret() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let off = byte_to_utf16(text.text(), caret);
|
||||||
|
caps_mode(&mut ctx.env, text.text(), off, req_modes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_surrounding_text(
|
||||||
|
&mut self,
|
||||||
|
ctx: &mut CallbackCtx,
|
||||||
|
before_length: i32,
|
||||||
|
after_length: i32,
|
||||||
|
) -> bool {
|
||||||
|
let Some(focus) = self.focus() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let text = &self.rsc[focus];
|
||||||
|
let Some(sel) = text.selection_range() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let content = text.text();
|
||||||
|
let start_16 =
|
||||||
|
byte_to_utf16(content, sel.start).saturating_sub(before_length.max(0) as usize);
|
||||||
|
let len_16 = byte_to_utf16(content, content.len());
|
||||||
|
let end_16 = (byte_to_utf16(content, sel.end) + after_length.max(0) as usize).min(len_16);
|
||||||
|
let start = utf16_to_byte(content, start_16);
|
||||||
|
let end = utf16_to_byte(content, end_16);
|
||||||
|
focus.edit(&mut self.rsc).delete_byte_range(start, end);
|
||||||
|
self.after_input(ctx);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_surrounding_text_in_code_points(
|
||||||
|
&mut self,
|
||||||
|
ctx: &mut CallbackCtx,
|
||||||
|
before_length: i32,
|
||||||
|
after_length: i32,
|
||||||
|
) -> bool {
|
||||||
|
// Approximated as UTF-16 units rather than Unicode scalar values --
|
||||||
|
// the two differ only outside the Basic Multilingual Plane, which
|
||||||
|
// this widget tree does not exercise today. Worth revisiting if a
|
||||||
|
// field ever needs to edit emoji or other astral-plane text well.
|
||||||
|
self.delete_surrounding_text(ctx, before_length, after_length)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_composing_text(
|
||||||
|
&mut self,
|
||||||
|
ctx: &mut CallbackCtx,
|
||||||
|
text: &str,
|
||||||
|
_new_cursor_position: i32,
|
||||||
|
) -> bool {
|
||||||
|
let Some(focus) = self.focus() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// The IME re-sends its whole composition on every keystroke;
|
||||||
|
// `compose_len` (chars, not bytes -- `TextEditCtx::replace`'s unit)
|
||||||
|
// is what lets `replace` remove exactly what it inserted last time.
|
||||||
|
// The same shape as `default::DefaultApp`'s `Ime::Preedit` handling
|
||||||
|
// for winit.
|
||||||
|
let compose_len = self.state.android_state().compose_len;
|
||||||
|
focus.edit(&mut self.rsc).replace(compose_len, text);
|
||||||
|
self.state.android_state_mut().compose_len = text.chars().count();
|
||||||
|
self.after_input(ctx);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_composing_region(&mut self, _ctx: &mut CallbackCtx, _start: i32, _end: i32) -> bool {
|
||||||
|
// `TextEdit` has no separate composing range to move -- see this
|
||||||
|
// module's doc comment. Declining (rather than moving the caret,
|
||||||
|
// which would surprise a caller expecting only a style change)
|
||||||
|
// is the safer approximation.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_composing_text(&mut self, ctx: &mut CallbackCtx) -> bool {
|
||||||
|
self.state.android_state_mut().compose_len = 0;
|
||||||
|
self.after_input(ctx);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_selection(&mut self, ctx: &mut CallbackCtx, start: i32, end: i32) -> bool {
|
||||||
|
let Some(focus) = self.focus() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let text = &self.rsc[focus];
|
||||||
|
let content = text.text();
|
||||||
|
// Collapsed to `end`: `TextEditCtx` has no range-selection setter
|
||||||
|
// yet (nothing before I2 needed one), so an IME-driven selection
|
||||||
|
// lands the caret at its focus end rather than spanning both.
|
||||||
|
let byte = utf16_to_byte(content, end.max(0) as usize);
|
||||||
|
focus.edit(&mut self.rsc).set_cursor_byte(byte);
|
||||||
|
let _ = start;
|
||||||
|
self.after_input(ctx);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn perform_editor_action(&mut self, _ctx: &mut CallbackCtx, _editor_action: i32) -> bool {
|
||||||
|
// `IME_FLAG_NO_ENTER_ACTION` above asks the IME not to offer one;
|
||||||
|
// nothing here needs handling it yet.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin_batch_edit(&mut self, _ctx: &mut CallbackCtx) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn end_batch_edit(&mut self, _ctx: &mut CallbackCtx) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_key_event<'local>(
|
||||||
|
&mut self,
|
||||||
|
ctx: &mut CallbackCtx<'local>,
|
||||||
|
event: &android_view::KeyEvent<'local>,
|
||||||
|
) -> bool {
|
||||||
|
let key_code = event.key_code(&mut ctx.env);
|
||||||
|
let handled = super::input::on_key(
|
||||||
|
&mut self.rsc,
|
||||||
|
&mut self.state,
|
||||||
|
&mut ctx.env,
|
||||||
|
key_code,
|
||||||
|
event,
|
||||||
|
);
|
||||||
|
if handled {
|
||||||
|
self.after_input(ctx);
|
||||||
|
}
|
||||||
|
handled
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_cursor_updates(&mut self, _ctx: &mut CallbackCtx, _cursor_update_mode: i32) -> bool {
|
||||||
|
// No cursor-anchor UI to feed -- see RUST.md's I2 notes on what
|
||||||
|
// this backend does not do yet.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
use crate::prelude::*;
|
||||||
|
use android_view::{jni::JNIEnv, ndk::event::Keycode};
|
||||||
|
|
||||||
|
use super::view::{AndroidAppState, AndroidRsc};
|
||||||
|
|
||||||
|
/// Hardware/synthesized key handling for the field that currently has
|
||||||
|
/// focus. Most typing on Android goes through the IME's `InputConnection`
|
||||||
|
/// (`android/ime.rs`) instead -- this only sees what a soft keyboard still
|
||||||
|
/// sends as a real `KeyEvent` in "not fullscreen" mode (Backspace, Enter,
|
||||||
|
/// the arrow keys on a physical keyboard) plus whatever `unicode_char`
|
||||||
|
/// reports for a plain key press. Returns whether anything used the event.
|
||||||
|
pub(super) fn on_key<'local, State: AndroidAppState>(
|
||||||
|
rsc: &mut AndroidRsc<State>,
|
||||||
|
state: &mut State,
|
||||||
|
env: &mut JNIEnv<'local>,
|
||||||
|
key_code: Keycode,
|
||||||
|
event: &android_view::KeyEvent<'local>,
|
||||||
|
) -> bool {
|
||||||
|
let Some(focus) = state.android_state().focus else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let mut text = focus.edit(rsc);
|
||||||
|
match key_code {
|
||||||
|
Keycode::Del => text.backspace(false),
|
||||||
|
Keycode::ForwardDel => text.delete(false),
|
||||||
|
Keycode::DpadLeft => text.motion(Motion::Left, false),
|
||||||
|
Keycode::DpadRight => text.motion(Motion::Right, false),
|
||||||
|
Keycode::DpadUp => text.motion(Motion::Up, false),
|
||||||
|
Keycode::DpadDown => text.motion(Motion::Down, false),
|
||||||
|
Keycode::MoveHome => text.motion(Motion::LineStart, false),
|
||||||
|
Keycode::MoveEnd => text.motion(Motion::LineEnd, false),
|
||||||
|
Keycode::Enter | Keycode::NumpadEnter => text.newline(),
|
||||||
|
_ => match event.unicode_char(env) {
|
||||||
|
Some(c) if !c.is_control() => text.insert(&c.to_string()),
|
||||||
|
_ => return false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//! Window insets, fed in from outside `ViewPeer`.
|
||||||
|
//!
|
||||||
|
//! android-view's registered native methods (`view.rs` in that crate) cover
|
||||||
|
//! touch, keys, focus, the surface and the IME -- there is nothing for
|
||||||
|
//! `View.onApplyWindowInsets`, because android-view's own demo does not
|
||||||
|
//! need it. The back gesture needed no new plumbing at all: with no
|
||||||
|
//! `OnBackPressedCallback` registered, Android still delivers it as an
|
||||||
|
//! ordinary `KEYCODE_BACK` `KeyEvent` through the ordinary key path (see
|
||||||
|
//! `view.rs`'s `on_key_down`), which is the legacy behaviour every app gets
|
||||||
|
//! by default and is enough for "the back gesture as an event". Insets have
|
||||||
|
//! no such stand-in, so this module registers one more native method by
|
||||||
|
//! hand, on the app's own `View` subclass rather than on android-view's.
|
||||||
|
//!
|
||||||
|
//! The peer id android-view hands back from `register_view_peer` is opaque
|
||||||
|
//! outside that crate (`with_peer` is `pub(crate)` there), so there is no
|
||||||
|
//! way to reach an existing `IrisViewPeer` from a JNI entry point we define
|
||||||
|
//! ourselves. Instead of forking android-view to add a hook, `new_peer`
|
||||||
|
//! (`view.rs`) inserts the *same* id into this module's own map, pointing
|
||||||
|
//! at a plain `Rc<RefCell<Shared>>` cloned into `AndroidUiState` too --
|
||||||
|
//! so writing here is reading there, with no dependency in either
|
||||||
|
//! direction on the other's internals.
|
||||||
|
|
||||||
|
use android_view::{
|
||||||
|
View,
|
||||||
|
jni::{
|
||||||
|
JNIEnv, NativeMethod,
|
||||||
|
descriptors::Desc,
|
||||||
|
objects::JClass,
|
||||||
|
sys::{jint, jlong},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
cell::RefCell,
|
||||||
|
collections::HashMap,
|
||||||
|
ffi::c_void,
|
||||||
|
rc::Rc,
|
||||||
|
sync::{Mutex, OnceLock},
|
||||||
|
};
|
||||||
|
|
||||||
|
use send_wrapper::SendWrapper;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Insets {
|
||||||
|
pub left: i32,
|
||||||
|
pub top: i32,
|
||||||
|
pub right: i32,
|
||||||
|
pub bottom: i32,
|
||||||
|
/// The keyboard's own inset (`WindowInsets.Type.ime()`), in physical
|
||||||
|
/// pixels, separate from `bottom` (the system bars): a layout wants to
|
||||||
|
/// know about the keyboard specifically, since it usually means "make
|
||||||
|
/// room" rather than "stay clear of a corner".
|
||||||
|
pub ime_bottom: i32,
|
||||||
|
/// `WindowInsets.isVisible(ime())` -- whether the keyboard is up, which
|
||||||
|
/// is **not** the same question as `ime_bottom > 0` and is why the two
|
||||||
|
/// are carried separately. They disagree for the frames the keyboard
|
||||||
|
/// spends sliding: visible, with a height still on its way to the full
|
||||||
|
/// one. Anything asking "make how much room" reads `ime_bottom`;
|
||||||
|
/// anything asking "is the keyboard up" reads this. See
|
||||||
|
/// `MainActivity.java`'s comment for the history -- the height used to
|
||||||
|
/// be sent *as* this boolean, which is what left the composer padded by
|
||||||
|
/// one pixel on Iris's phone.
|
||||||
|
pub ime_visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Shared {
|
||||||
|
pub insets: Insets,
|
||||||
|
/// How many times Java has called `applyWindowInsetsNative` for this
|
||||||
|
/// peer, whether or not the numbers changed. Deliberately **not** a
|
||||||
|
/// field of `Insets`, which is compared for equality each frame to
|
||||||
|
/// decide whether to re-run `on_insets_changed`; a counter in there
|
||||||
|
/// would make every dispatch look like a change.
|
||||||
|
///
|
||||||
|
/// It exists because "the keyboard does not push anything up" has two
|
||||||
|
/// completely different causes that look identical on screen -- the
|
||||||
|
/// listener never fired, or it fired with a zero `ime_bottom` -- and
|
||||||
|
/// Iris has no logcat on her phone (docs/IRIS_TODO.md). This number is
|
||||||
|
/// in the `Diagnostics` overlay, so one screenshot separates them.
|
||||||
|
pub updates: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
type SharedMap = HashMap<jlong, SendWrapper<Rc<RefCell<Shared>>>>;
|
||||||
|
|
||||||
|
fn map() -> &'static Mutex<SharedMap> {
|
||||||
|
static MAP: OnceLock<Mutex<SharedMap>> = OnceLock::new();
|
||||||
|
MAP.get_or_init(Default::default)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called from `view::new_peer` with the same id android-view's
|
||||||
|
/// `register_view_peer` returned, so a later `apply_window_insets` call
|
||||||
|
/// (keyed on that id by Java, which only ever sees the one long) reaches
|
||||||
|
/// the same `Shared` cell `AndroidUiState` reads from.
|
||||||
|
pub(super) fn register(id: jlong, shared: Rc<RefCell<Shared>>) {
|
||||||
|
map().lock().unwrap().insert(id, SendWrapper::new(shared));
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "system" fn unregister_insets<'local>(
|
||||||
|
_env: JNIEnv<'local>,
|
||||||
|
_view: View<'local>,
|
||||||
|
peer: jlong,
|
||||||
|
) {
|
||||||
|
map().lock().unwrap().remove(&peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "system" fn apply_window_insets<'local>(
|
||||||
|
mut env: JNIEnv<'local>,
|
||||||
|
view: View<'local>,
|
||||||
|
peer: jlong,
|
||||||
|
left: jint,
|
||||||
|
top: jint,
|
||||||
|
right: jint,
|
||||||
|
bottom: jint,
|
||||||
|
ime_bottom: jint,
|
||||||
|
ime_visible: jint,
|
||||||
|
) {
|
||||||
|
if let Some(shared) = map().lock().unwrap().get(&peer) {
|
||||||
|
let mut shared = shared.borrow_mut();
|
||||||
|
shared.insets = Insets {
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
right,
|
||||||
|
bottom,
|
||||||
|
ime_bottom,
|
||||||
|
ime_visible: ime_visible != 0,
|
||||||
|
};
|
||||||
|
shared.updates += 1;
|
||||||
|
}
|
||||||
|
// Insets can change (the keyboard opening) with no resize and no
|
||||||
|
// touch, so nothing else here would otherwise ask for a frame.
|
||||||
|
view.post_frame_callback(&mut env);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers `applyWindowInsetsNative` on the app's own `View` subclass.
|
||||||
|
/// Called once from `JNI_OnLoad` alongside `android_view::register_view_class`.
|
||||||
|
pub fn register_native_methods<'local, 'other_local>(
|
||||||
|
env: &mut JNIEnv<'local>,
|
||||||
|
class: impl Desc<'local, JClass<'other_local>>,
|
||||||
|
) {
|
||||||
|
env.register_native_methods(
|
||||||
|
class,
|
||||||
|
&[
|
||||||
|
NativeMethod {
|
||||||
|
name: "applyWindowInsetsNative".into(),
|
||||||
|
sig: "(JIIIIII)V".into(),
|
||||||
|
fn_ptr: apply_window_insets as *mut c_void,
|
||||||
|
},
|
||||||
|
NativeMethod {
|
||||||
|
name: "unregisterInsetsNative".into(),
|
||||||
|
sig: "(J)V".into(),
|
||||||
|
fn_ptr: unregister_insets as *mut c_void,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//! iris's second windowing backend: `android-view` (a `SurfaceView` plus a
|
||||||
|
//! JNI `ViewPeer`) instead of winit. See RUST.md's I2 for why this exists
|
||||||
|
//! as a second backend rather than winit's own (unfinished, and blocked on
|
||||||
|
//! `android-activity`'s backend-feature requirement) Android support, and
|
||||||
|
//! for the pass condition this was built against.
|
||||||
|
//!
|
||||||
|
//! Structured to mirror `default/` module for module: `view.rs` is that
|
||||||
|
//! module's `app.rs` + `state.rs` combined (android-view has one harness
|
||||||
|
//! type, `ViewPeer`, where winit splits `ApplicationHandler` from the
|
||||||
|
//! per-window state), `render.rs` is `render.rs`, `input.rs` is `input.rs`,
|
||||||
|
//! `attr.rs` is `attr.rs`. `ime.rs` and `insets.rs` have no winit
|
||||||
|
//! counterpart: winit cannot drive an IME beyond `Ime::Preedit`/`Commit`
|
||||||
|
//! (RUST.md's E1) and has no concept of Android's window insets at all.
|
||||||
|
|
||||||
|
mod access;
|
||||||
|
mod attr;
|
||||||
|
mod ime;
|
||||||
|
mod input;
|
||||||
|
mod insets;
|
||||||
|
mod platform;
|
||||||
|
mod render;
|
||||||
|
mod view;
|
||||||
|
|
||||||
|
pub use insets::Insets;
|
||||||
|
pub use render::AndroidRenderer;
|
||||||
|
pub use view::{
|
||||||
|
AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState, IrisViewPeer, WindowInsets,
|
||||||
|
new_peer,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Registers the extra native methods this backend needs beyond what
|
||||||
|
/// `android_view::register_view_class` covers (window insets -- see
|
||||||
|
/// `insets.rs`'s doc comment for why that one could not ride along on an
|
||||||
|
/// existing android-view callback the way the back gesture does). Call
|
||||||
|
/// from `JNI_OnLoad` alongside `register_view_class`, on the same `View`
|
||||||
|
/// subclass.
|
||||||
|
pub fn register_native_methods<'local, 'other_local>(
|
||||||
|
env: &mut android_view::jni::JNIEnv<'local>,
|
||||||
|
class: impl android_view::jni::descriptors::Desc<
|
||||||
|
'local,
|
||||||
|
android_view::jni::objects::JClass<'other_local>,
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
insets::register_native_methods(env, class);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
use crate::platform::OpenUrl;
|
||||||
|
use android_view::{
|
||||||
|
View,
|
||||||
|
jni::{JNIEnv, objects::JValue},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::view::HasAndroidUiState;
|
||||||
|
|
||||||
|
/// Android's URL opener. Like `FocusHost::focus_gained`'s keyboard, the
|
||||||
|
/// real work is a JNI call and this runs deep inside the sensor dispatch
|
||||||
|
/// with no `CallbackCtx` in reach -- so it raises a flag that
|
||||||
|
/// `IrisViewPeer::after_input` consumes, exactly as
|
||||||
|
/// `pending_show_keyboard` does.
|
||||||
|
///
|
||||||
|
/// Last request wins: two links cannot be tapped in one frame, and a URL
|
||||||
|
/// left queued from a frame that somehow never reached `after_input`
|
||||||
|
/// would open at some unrelated later tap, which is worse than dropping
|
||||||
|
/// it.
|
||||||
|
impl<T: HasAndroidUiState> OpenUrl for T {
|
||||||
|
fn open_url(&mut self, url: &str) {
|
||||||
|
self.android_state_mut().pending_open_url = Some(url.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the view's
|
||||||
|
/// own context.
|
||||||
|
///
|
||||||
|
/// `FLAG_ACTIVITY_NEW_TASK` because the context here is the view's, which
|
||||||
|
/// may be an application context rather than the activity's -- Android
|
||||||
|
/// throws `AndroidRuntimeException` for a non-activity context without it,
|
||||||
|
/// and it is harmless when the context *is* an activity's.
|
||||||
|
///
|
||||||
|
/// Every failure is logged with the URL and returns; there is nothing to
|
||||||
|
/// fall back to, and the reader will see that nothing happened.
|
||||||
|
pub(super) fn open_url<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, url: &str) {
|
||||||
|
match try_open_url(env, view, url) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) => {
|
||||||
|
// A pending Java exception makes every later JNI call fail in
|
||||||
|
// ways nowhere near here, so it is cleared at the boundary.
|
||||||
|
let _ = env.exception_clear();
|
||||||
|
log::warn!("could not open {url}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_open_url<'local>(
|
||||||
|
env: &mut JNIEnv<'local>,
|
||||||
|
view: &View<'local>,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<(), android_view::jni::errors::Error> {
|
||||||
|
let context = env
|
||||||
|
.call_method(&view.0, "getContext", "()Landroid/content/Context;", &[])?
|
||||||
|
.l()?;
|
||||||
|
let jurl = env.new_string(url)?;
|
||||||
|
let uri = env.call_static_method(
|
||||||
|
"android/net/Uri",
|
||||||
|
"parse",
|
||||||
|
"(Ljava/lang/String;)Landroid/net/Uri;",
|
||||||
|
&[JValue::Object(jurl.as_ref())],
|
||||||
|
)?;
|
||||||
|
let action = env.new_string("android.intent.action.VIEW")?;
|
||||||
|
let intent = env.new_object(
|
||||||
|
"android/content/Intent",
|
||||||
|
"(Ljava/lang/String;Landroid/net/Uri;)V",
|
||||||
|
&[JValue::Object(action.as_ref()), JValue::Object(&uri.l()?)],
|
||||||
|
)?;
|
||||||
|
env.call_method(
|
||||||
|
&intent,
|
||||||
|
"addFlags",
|
||||||
|
"(I)Landroid/content/Intent;",
|
||||||
|
&[JValue::Int(FLAG_ACTIVITY_NEW_TASK)],
|
||||||
|
)?;
|
||||||
|
env.call_method(
|
||||||
|
&context,
|
||||||
|
"startActivity",
|
||||||
|
"(Landroid/content/Intent;)V",
|
||||||
|
&[JValue::Object(&intent)],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `android.content.Intent.FLAG_ACTIVITY_NEW_TASK`. A constant rather than
|
||||||
|
/// a static-field read: it is part of the platform's stable ABI and
|
||||||
|
/// reading it costs two more JNI calls that can each fail.
|
||||||
|
const FLAG_ACTIVITY_NEW_TASK: i32 = 0x1000_0000;
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
use crate::task::RequestRedraw;
|
||||||
|
use android_view::{
|
||||||
|
View,
|
||||||
|
jni::{JavaVM, objects::GlobalRef},
|
||||||
|
ndk::native_window::NativeWindow,
|
||||||
|
};
|
||||||
|
use iris_core::{UiData, UiRenderNode, UiRenderState};
|
||||||
|
use pollster::FutureExt;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use wgpu::{
|
||||||
|
rwh::{DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WindowHandle},
|
||||||
|
*,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const CLEAR_COLOR: Color = Color::BLACK;
|
||||||
|
|
||||||
|
/// `NativeWindow` (from the surface android-view hands over in
|
||||||
|
/// `surfaceChanged`) has a window handle but not a display one -- there is
|
||||||
|
/// exactly one display on Android and `rwh` has a unit variant for it.
|
||||||
|
/// Mirrors android-view's own demo (`demo/src/lib.rs`'s
|
||||||
|
/// `AndroidWindowHandle`).
|
||||||
|
struct AndroidWindowHandle {
|
||||||
|
window: NativeWindow,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasDisplayHandle for AndroidWindowHandle {
|
||||||
|
fn display_handle(&self) -> Result<DisplayHandle<'_>, HandleError> {
|
||||||
|
Ok(DisplayHandle::android())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasWindowHandle for AndroidWindowHandle {
|
||||||
|
fn window_handle(&self) -> Result<WindowHandle<'_>, HandleError> {
|
||||||
|
self.window.window_handle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The android-view surface, unlike winit's window, does not outlive a
|
||||||
|
/// backgrounding of the activity: `surfaceDestroyed`/`surfaceCreated` (via
|
||||||
|
/// `SurfaceHolder.Callback`) recreate it, so this holds everything that
|
||||||
|
/// depends on that surface rather than being built once at startup --
|
||||||
|
/// `AndroidUiState` holds it as `Option<AndroidRenderer>`, `None` exactly
|
||||||
|
/// when there is no surface to draw into.
|
||||||
|
pub struct AndroidRenderer {
|
||||||
|
surface: Surface<'static>,
|
||||||
|
device: Device,
|
||||||
|
queue: Queue,
|
||||||
|
config: SurfaceConfiguration,
|
||||||
|
encoder: CommandEncoder,
|
||||||
|
pub ui: UiRenderNode,
|
||||||
|
/// The adapter identity, kept past `new()` for the Diagnostics page --
|
||||||
|
/// `Adapter` itself is not `Clone`, so the three fields the page shows
|
||||||
|
/// are copied out once here rather than holding the adapter.
|
||||||
|
pub adapter_name: String,
|
||||||
|
pub adapter_backend: Backend,
|
||||||
|
pub adapter_driver: String,
|
||||||
|
/// Every uncaptured wgpu error since this renderer was created -- see
|
||||||
|
/// `iris_core::WgpuErrorLog`'s doc comment. Installed on `device` in
|
||||||
|
/// `new()`, kept here so the Diagnostics page and the per-frame log in
|
||||||
|
/// `update()` can both read it without a global.
|
||||||
|
pub wgpu_errors: iris_core::WgpuErrorLog,
|
||||||
|
/// Frames drawn on this surface -- what gates the first-10-frames log
|
||||||
|
/// `update()` writes (RUST.md's P0 box, "the first input frame"
|
||||||
|
/// investigation): a fresh surface is exactly what Iris's own report
|
||||||
|
/// says renders correctly at first, so the frames that matter are the
|
||||||
|
/// first several after each `surface_changed`, not an arbitrary window
|
||||||
|
/// during a long-running session.
|
||||||
|
frame_count: u64,
|
||||||
|
/// Physical pixels per dp -- see `android::view::AndroidUiState::
|
||||||
|
/// content_scale`'s field comment for what this feeds.
|
||||||
|
content_scale: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One frame's worth of the counters `render/mod.rs`'s doc comments on
|
||||||
|
/// `FrameUpdateStats`/`take_image_bind_group_creates`/
|
||||||
|
/// `take_atlas_pages_grown` describe -- assembled here because the three
|
||||||
|
/// live on two different calling conventions (`FrameUpdateStats` from this
|
||||||
|
/// exact `update()` call; the other two describe the *previous* frame,
|
||||||
|
/// same as `bench_images`' existing use of them) and a diagnostic reader
|
||||||
|
/// should not have to know that split.
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
pub struct FrameDiagnostics {
|
||||||
|
pub masks_resized: bool,
|
||||||
|
pub moves_resized: bool,
|
||||||
|
/// From the previous frame's `update()` -- see the struct doc.
|
||||||
|
pub atlas_pages_grown_prev: u64,
|
||||||
|
pub image_bind_group_creates_prev: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidRenderer {
|
||||||
|
/// `Err` holds a full, human-readable report for **every** way this
|
||||||
|
/// can fail -- no surface, no adapter, no device, or wgpu's own error
|
||||||
|
/// text (`UiRenderNode::new`'s doc comment) plus the adapter identity
|
||||||
|
/// and the limits/downlevel flags bind-group-layout validation checks
|
||||||
|
/// against -- rather than the panic wgpu's default error handler would
|
||||||
|
/// otherwise raise with no caller able to see it. This is what aborted
|
||||||
|
/// the P0 bench APK on Iris's phone with only "wgpu error: Validation
|
||||||
|
/// Error" surviving into the crash report (RUST.md's P0 box, "iris
|
||||||
|
/// bench crash on the phone, 2026-09-06"): `create_bind_group_layout`
|
||||||
|
/// validates against *this* adapter's downlevel capabilities and
|
||||||
|
/// limits, which a desktop GPU and the emulator's software renderers
|
||||||
|
/// never exercised. The caller (`android::view::IrisViewPeer::
|
||||||
|
/// surface_changed`) logs this one-line-flattened and shows it on
|
||||||
|
/// screen instead of aborting the process.
|
||||||
|
pub fn new(
|
||||||
|
window: NativeWindow,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
content_scale: f32,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
// `force-gles` (RUST.md's I5 "Where iris's frame time goes") pins
|
||||||
|
// the build to GLES, to isolate whether the backend itself explains
|
||||||
|
// the frame time gap against Compose. `cfg!` rather than a runtime
|
||||||
|
// switch: there is no way to hand an env var to an already-launched
|
||||||
|
// Android process on this machine (see the feature's doc in
|
||||||
|
// Cargo.toml).
|
||||||
|
//
|
||||||
|
// Otherwise: **Vulkan where it has an adapter at all, GLES where it
|
||||||
|
// has none.** `Backends::PRIMARY` leaves `GL` out, so a device
|
||||||
|
// offering only a GLES adapter had no adapter at all and this
|
||||||
|
// function aborted the process -- this checkout's emulator, whose
|
||||||
|
// Vulkan ICD carries no adapter behind it (`NotFound {
|
||||||
|
// active_backends: VULKAN, no_adapter_backends: VULKAN,
|
||||||
|
// supported_backends: VULKAN | GL }`), and the crash loop in
|
||||||
|
// RUST.md's queue.
|
||||||
|
//
|
||||||
|
// The choice is made *before any surface exists*, with an instance
|
||||||
|
// that never touches the window, because **an Android window can be
|
||||||
|
// connected to one graphics API only**. One instance carrying both
|
||||||
|
// backends does not work: `create_surface` builds a raw surface per
|
||||||
|
// backend, Vulkan's `vkCreateAndroidSurfaceKHR` claims the window
|
||||||
|
// first, and the GLES surface made from the same window then fails
|
||||||
|
// `configure` as lost -- measured here as "In Surface::configure /
|
||||||
|
// Invalid surface" followed by an abort in
|
||||||
|
// `Surface::get_current_texture_view`, "Surface is not configured
|
||||||
|
// for presentation".
|
||||||
|
let mut backends = if cfg!(feature = "force-gles") {
|
||||||
|
Backends::GL
|
||||||
|
} else {
|
||||||
|
Backends::PRIMARY
|
||||||
|
};
|
||||||
|
// No display handle: an Android surface is built from the
|
||||||
|
// `NativeWindow` below, and there is no platform connection to hand
|
||||||
|
// wgpu here the way there is on Wayland.
|
||||||
|
let mut instance = Instance::new(InstanceDescriptor {
|
||||||
|
backends,
|
||||||
|
..InstanceDescriptor::new_without_display_handle()
|
||||||
|
});
|
||||||
|
// A build already pinned to GLES has nowhere to fall back to.
|
||||||
|
if backends != Backends::GL && instance.enumerate_adapters(backends).block_on().is_empty() {
|
||||||
|
log::warn!(
|
||||||
|
"iris renderer: no {backends:?} adapter on this device, falling back to GLES"
|
||||||
|
);
|
||||||
|
backends = Backends::GL;
|
||||||
|
instance = Instance::new(InstanceDescriptor {
|
||||||
|
backends,
|
||||||
|
..InstanceDescriptor::new_without_display_handle()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: the `NativeWindow` outlives the surface built from it --
|
||||||
|
// android-view drops the old renderer (and this surface with it)
|
||||||
|
// before handing over a new window, in `surface_changed` below.
|
||||||
|
let surface = instance
|
||||||
|
.create_surface(SurfaceTarget::from(AndroidWindowHandle { window }))
|
||||||
|
.map_err(|error| format!("Could not create the android surface: {error}"))?;
|
||||||
|
|
||||||
|
// Every step from here to a live device reports rather than
|
||||||
|
// panics, for the one reason: on the phone these builds run on
|
||||||
|
// there is no `adb`, so an abort's message reaches a tombstone
|
||||||
|
// nobody can read and the launcher simply restarts the app --
|
||||||
|
// which is what a crash loop with no explanation is. The caller
|
||||||
|
// (`android::view::IrisViewPeer::surface_changed`) puts this
|
||||||
|
// string on screen and in the app's own log ring instead.
|
||||||
|
let adapter = instance
|
||||||
|
.request_adapter(&RequestAdapterOptions {
|
||||||
|
power_preference: PowerPreference::default(),
|
||||||
|
compatible_surface: Some(&surface),
|
||||||
|
force_fallback_adapter: false,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.block_on()
|
||||||
|
.map_err(|error| format!("No usable GPU adapter for backends {backends:?}: {error}"))?;
|
||||||
|
|
||||||
|
// Same request as the winit backend's `UiRenderer::new` -- no
|
||||||
|
// binding-array features, see TEXTURES.md's "Recommended shape".
|
||||||
|
// `iris_core::device_limits()` is shared between the two backends;
|
||||||
|
// see its own doc for why it is not simply `Limits::default()`.
|
||||||
|
let (device, queue) = adapter
|
||||||
|
.request_device(&DeviceDescriptor {
|
||||||
|
required_limits: iris_core::device_limits(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.block_on()
|
||||||
|
.map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"The adapter {} ({:?}) refused a device: {error}",
|
||||||
|
adapter.get_info().name,
|
||||||
|
adapter.get_info().backend,
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// wgpu's default handler for an error raised outside `UiRenderNode::
|
||||||
|
// new`'s own error scopes (i.e. everything past device creation --
|
||||||
|
// an ordinary frame's `update`/`draw`) is `panic!`, unconditionally,
|
||||||
|
// with no caller able to intervene: the same mechanism that aborted
|
||||||
|
// the P0 bench APK once already, just at a different call site. Log
|
||||||
|
// and record instead of letting that default stand -- RUST.md's P0
|
||||||
|
// box, "every wgpu uncaptured error ... it must never panic in
|
||||||
|
// release".
|
||||||
|
let wgpu_errors = iris_core::WgpuErrorLog::default();
|
||||||
|
let wgpu_errors_for_handler = wgpu_errors.clone();
|
||||||
|
device.on_uncaptured_error(std::sync::Arc::new(move |error| {
|
||||||
|
log::error!("iris wgpu uncaptured error: {error}");
|
||||||
|
wgpu_errors_for_handler.record(error);
|
||||||
|
}));
|
||||||
|
|
||||||
|
let info = adapter.get_info();
|
||||||
|
let adapter_name = info.name.clone();
|
||||||
|
let adapter_backend = info.backend;
|
||||||
|
// Either half can be empty -- the emulator's GLES adapter reports
|
||||||
|
// no `driver` and a long `driver_info`, so joining unconditionally
|
||||||
|
// left a leading space in every log line it appears in.
|
||||||
|
let adapter_driver = [info.driver.as_str(), info.driver_info.as_str()]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
// Say which adapter won, in the same words `default::render` uses,
|
||||||
|
// and at startup rather than only on the Diagnostics page: the
|
||||||
|
// backend alone (logged by `view.rs` when a renderer is built) does
|
||||||
|
// not separate the cases that matter. In this checkout's emulator
|
||||||
|
// `Gl` is the host's real GPU through virgl, and `Gl` under
|
||||||
|
// `EMU_GPU=software` is SwiftShader on the CPU; on a phone `Vulkan`
|
||||||
|
// is the device's own driver. A frame time or a screenshot with no
|
||||||
|
// record of which of those produced it cannot be read, and the
|
||||||
|
// fallback above is silent by design.
|
||||||
|
log::info!(
|
||||||
|
"iris renderer: {adapter_name} ({adapter_backend:?}, {adapter_driver}) on \
|
||||||
|
{backends:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let surface_caps = surface.get_capabilities(&adapter);
|
||||||
|
let surface_format = surface_caps
|
||||||
|
.formats
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|f| f.is_srgb())
|
||||||
|
.unwrap_or(surface_caps.formats[0]);
|
||||||
|
|
||||||
|
let config = SurfaceConfiguration {
|
||||||
|
usage: TextureUsages::RENDER_ATTACHMENT,
|
||||||
|
format: surface_format,
|
||||||
|
// wgpu 30's new field; `Auto` is what every earlier version did.
|
||||||
|
color_space: SurfaceColorSpace::Auto,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
present_mode: PresentMode::AutoVsync,
|
||||||
|
alpha_mode: surface_caps.alpha_modes[0],
|
||||||
|
desired_maximum_frame_latency: 2,
|
||||||
|
view_formats: vec![],
|
||||||
|
};
|
||||||
|
surface.configure(&device, &config);
|
||||||
|
|
||||||
|
let encoder = Self::create_encoder(&device);
|
||||||
|
// Physical pixels, matching the swapchain's own `width`/`height`
|
||||||
|
// exactly -- see `android::view::AndroidUiState::content_scale`'s
|
||||||
|
// field comment for why this is no longer divided into a separate
|
||||||
|
// logical space (that stopgap is what made text blurry, RUST.md's
|
||||||
|
// P0 box). `Len::dp` folds the density in at layout time instead,
|
||||||
|
// so nothing here needs to know it at all.
|
||||||
|
let window_size = iris_core::util::Vec2::new(width as f32, height as f32);
|
||||||
|
let ui = match UiRenderNode::new(&device, &queue, &config, window_size) {
|
||||||
|
Ok(ui) => ui,
|
||||||
|
Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
surface,
|
||||||
|
device,
|
||||||
|
queue,
|
||||||
|
config,
|
||||||
|
encoder,
|
||||||
|
ui,
|
||||||
|
adapter_name,
|
||||||
|
adapter_backend,
|
||||||
|
adapter_driver,
|
||||||
|
wgpu_errors,
|
||||||
|
frame_count: 0,
|
||||||
|
content_scale,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The adapter identity plus every limit and downlevel flag
|
||||||
|
/// `create_bind_group_layout` validates a storage buffer or texture
|
||||||
|
/// binding against, followed by wgpu's own error text -- everything a
|
||||||
|
/// person reading this off a screenshot needs to tell "this adapter
|
||||||
|
/// lacks X" from "this is a bug in the layout." Named explicitly rather
|
||||||
|
/// than `{limits:?}`/`{flags:?}` wholesale, because `Limits` alone is
|
||||||
|
/// dozens of fields nobody asked for -- these are exactly the ones
|
||||||
|
/// `UiRenderNode::new`'s layouts (`rsc_layout`, `masks_layout`,
|
||||||
|
/// `primitive_layout`) can fail against, per `CreateBindGroupLayoutError`
|
||||||
|
/// (`wgpu-core::binding_model`) and its downlevel-flag checks
|
||||||
|
/// (`wgpu-core::device::resource`, `VERTEX_STORAGE` in particular --
|
||||||
|
/// the one storage buffer here, `move_offsets`, that is visible to the
|
||||||
|
/// vertex stage).
|
||||||
|
fn diagnostic(adapter: &Adapter, wgpu_error: &str) -> String {
|
||||||
|
let info = adapter.get_info();
|
||||||
|
let limits = adapter.limits();
|
||||||
|
let downlevel = adapter.get_downlevel_capabilities();
|
||||||
|
format!(
|
||||||
|
"iris could not start rendering. Copy this text and send it to Iris.\n\n\
|
||||||
|
adapter: {name} ({backend:?}), driver: {driver} {driver_info}\n\
|
||||||
|
limits: max_storage_buffers_per_shader_stage={max_storage_buffers} \
|
||||||
|
max_sampled_textures_per_shader_stage={max_sampled_textures} \
|
||||||
|
max_bind_groups={max_bind_groups} \
|
||||||
|
max_bindings_per_bind_group={max_bindings} \
|
||||||
|
max_storage_buffer_binding_size={max_storage_binding} \
|
||||||
|
min_storage_buffer_offset_alignment={min_storage_align}\n\
|
||||||
|
downlevel flags: {flags:?}\n\n\
|
||||||
|
{wgpu_error}",
|
||||||
|
name = info.name,
|
||||||
|
backend = info.backend,
|
||||||
|
driver = info.driver,
|
||||||
|
driver_info = info.driver_info,
|
||||||
|
max_storage_buffers = limits.max_storage_buffers_per_shader_stage,
|
||||||
|
max_sampled_textures = limits.max_sampled_textures_per_shader_stage,
|
||||||
|
max_bind_groups = limits.max_bind_groups,
|
||||||
|
max_bindings = limits.max_bindings_per_bind_group,
|
||||||
|
max_storage_binding = limits.max_storage_buffer_binding_size,
|
||||||
|
min_storage_align = limits.min_storage_buffer_offset_alignment,
|
||||||
|
flags = downlevel.flags,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Diagnostics page's whole report: adapter identity, font
|
||||||
|
/// resolution, the atlas's own view count, every uncaptured wgpu error
|
||||||
|
/// so far, and the frame report -- RUST.md's P0 box, "a named
|
||||||
|
/// `Diagnostics` control ... adapter info, limits, fonts found, atlas
|
||||||
|
/// format/pages, wgpu errors so far, frame report". One string rather
|
||||||
|
/// than a struct the caller formats, since the only consumer is a
|
||||||
|
/// plain `TextView` with a "copy this and send it to Iris" affordance,
|
||||||
|
/// the same shape `surface_changed`'s crash report already uses
|
||||||
|
/// (UI_RULES.md: a failure -- or here, a state worth reporting --
|
||||||
|
/// carries enough to act on where it's shown).
|
||||||
|
pub fn diagnostics_report(
|
||||||
|
&self,
|
||||||
|
font: &iris_core::FontDiagnostics,
|
||||||
|
frame_report: &str,
|
||||||
|
) -> String {
|
||||||
|
let errors = self.wgpu_errors.snapshot();
|
||||||
|
let errors_text = if errors.is_empty() {
|
||||||
|
"none".to_string()
|
||||||
|
} else {
|
||||||
|
errors.join("\n ")
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"iris diagnostics. Copy this text and send it to Iris.\n\n\
|
||||||
|
adapter: {name} ({backend:?}), driver: {driver}\n\
|
||||||
|
content_scale: {content_scale}\n\
|
||||||
|
atlas format: Rgba8Unorm, views live: {views}\n\
|
||||||
|
fonts: {families_found} families found, default={default_family:?} \
|
||||||
|
mono={default_mono_family:?}\n\
|
||||||
|
fonts resolved: regular={regular:?} bold={bold:?} italic={italic:?} \
|
||||||
|
mono={mono:?}\n\
|
||||||
|
icon font: {icons:?}\n\
|
||||||
|
wgpu errors since surface creation:\n {errors_text}\n\n\
|
||||||
|
{frame_report}",
|
||||||
|
name = self.adapter_name,
|
||||||
|
backend = self.adapter_backend,
|
||||||
|
driver = self.adapter_driver,
|
||||||
|
content_scale = self.content_scale,
|
||||||
|
views = self.ui.view_count(),
|
||||||
|
families_found = font.families_found,
|
||||||
|
default_family = font.default_family,
|
||||||
|
default_mono_family = font.default_mono_family,
|
||||||
|
regular = font.regular_resolved,
|
||||||
|
bold = font.bold_resolved,
|
||||||
|
italic = font.italic_resolved,
|
||||||
|
mono = font.mono_resolved,
|
||||||
|
icons = font.icon_family,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_encoder(device: &Device) -> CommandEncoder {
|
||||||
|
device.create_command_encoder(&CommandEncoderDescriptor {
|
||||||
|
label: Some("Render Encoder"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns what changed this frame -- see `FrameDiagnostics`'s doc
|
||||||
|
/// comment for why two of its four fields describe the *previous*
|
||||||
|
/// frame rather than this one. `IrisViewPeer::render` logs this for
|
||||||
|
/// the first `DIAGNOSTIC_FRAMES` frames after each `surface_changed`,
|
||||||
|
/// per RUST.md's P0 box ("the first input frame" investigation): the
|
||||||
|
/// glyph-wipe Iris reported happens on the first tap or scroll after a
|
||||||
|
/// fresh surface, so that is exactly the window a report needs to
|
||||||
|
/// cover, not an arbitrary slice of a long session.
|
||||||
|
pub fn update(&mut self, ui: &mut UiData, render: &mut UiRenderState) -> FrameDiagnostics {
|
||||||
|
let atlas_pages_grown_prev = self.ui.take_atlas_pages_grown();
|
||||||
|
let image_bind_group_creates_prev = self.ui.take_image_bind_group_creates();
|
||||||
|
let stats = self.ui.update(&self.device, &self.queue, ui, render);
|
||||||
|
self.frame_count += 1;
|
||||||
|
FrameDiagnostics {
|
||||||
|
masks_resized: stats.masks_resized,
|
||||||
|
moves_resized: stats.moves_resized,
|
||||||
|
atlas_pages_grown_prev,
|
||||||
|
image_bind_group_creates_prev,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frames drawn on this surface so far -- see `frame_count`'s field
|
||||||
|
/// comment.
|
||||||
|
pub fn frame_count(&self) -> u64 {
|
||||||
|
self.frame_count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws and presents one frame, returning the time spent in
|
||||||
|
/// `queue.submit` plus `present()` -- wherever a driver/GPU/compositor
|
||||||
|
/// wait would actually show up. The caller (`android::view::render`)
|
||||||
|
/// already times the whole frame from its own `redraw_to_submit` start;
|
||||||
|
/// subtracting this from that total is `redraw_to_submit` itself
|
||||||
|
/// (layout, text, primitive building, and this method's own render-pass
|
||||||
|
/// recording). RUST.md's I5 "Where iris's frame time goes" diagnosis,
|
||||||
|
/// added 2026-09-05 -- see `iris_core::FrameReport::record_split`'s own
|
||||||
|
/// doc for the caveat this shares: `present()` is not fenced against
|
||||||
|
/// the GPU actually finishing, so this is "how long the CPU was blocked
|
||||||
|
/// handing the frame off", not confirmed GPU time.
|
||||||
|
pub fn draw(&mut self) -> Duration {
|
||||||
|
let output = match self.surface.get_current_texture() {
|
||||||
|
CurrentSurfaceTexture::Success(texture)
|
||||||
|
| CurrentSurfaceTexture::Suboptimal(texture) => texture,
|
||||||
|
// wgpu 30 turned this Result into an enum; every arm here was an
|
||||||
|
// `Err` the previous `.unwrap()` panicked on, except `Occluded`,
|
||||||
|
// which is new.
|
||||||
|
other => panic!("no surface texture to draw into: {other:?}"),
|
||||||
|
};
|
||||||
|
let view = output
|
||||||
|
.texture
|
||||||
|
.create_view(&TextureViewDescriptor::default());
|
||||||
|
|
||||||
|
let mut encoder = std::mem::replace(&mut self.encoder, Self::create_encoder(&self.device));
|
||||||
|
{
|
||||||
|
let render_pass = &mut encoder.begin_render_pass(&RenderPassDescriptor {
|
||||||
|
color_attachments: &[Some(RenderPassColorAttachment {
|
||||||
|
view: &view,
|
||||||
|
resolve_target: None,
|
||||||
|
ops: Operations {
|
||||||
|
load: LoadOp::Clear(CLEAR_COLOR),
|
||||||
|
store: StoreOp::Store,
|
||||||
|
},
|
||||||
|
depth_slice: None,
|
||||||
|
})],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
self.ui.draw(render_pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
let submit_start = Instant::now();
|
||||||
|
self.queue.submit(std::iter::once(encoder.finish()));
|
||||||
|
self.queue.present(output);
|
||||||
|
submit_start.elapsed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Physical pixels -- the unit layout and hit-testing use, matching
|
||||||
|
/// the window uniform's own units. See
|
||||||
|
/// `android::view::AndroidUiState::content_scale`'s field comment.
|
||||||
|
pub fn size(&self) -> iris_core::util::Vec2 {
|
||||||
|
iris_core::util::Vec2::new(self.config.width as f32, self.config.height as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconfigures the surface and rewrites the window uniform for a new
|
||||||
|
/// physical size -- deliberately the *only* two things this does.
|
||||||
|
/// `device`, `ui`'s atlas, buffers and bind groups are untouched, so a
|
||||||
|
/// call here (as opposed to a fresh `AndroidRenderer::new`) never
|
||||||
|
/// invalidates a glyph the CPU-side cache already placed in the atlas.
|
||||||
|
/// See `android::view::IrisViewPeer::surface_changed`'s doc comment for
|
||||||
|
/// why that distinction matters -- it is what keeps text on screen
|
||||||
|
/// across an IME resize.
|
||||||
|
pub fn resize(&mut self, width: u32, height: u32) {
|
||||||
|
self.config.width = width;
|
||||||
|
self.config.height = height;
|
||||||
|
self.surface.configure(&self.device, &self.config);
|
||||||
|
let size = iris_core::util::Vec2::new(width as f32, height as f32);
|
||||||
|
self.ui.resize(size, &self.queue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Tasks`' redraw handle on Android: a background task finishes on the
|
||||||
|
/// tokio thread `Tasks::init` spawned, which is not attached to the JVM, so
|
||||||
|
/// asking for a frame means attaching first. The global ref is what
|
||||||
|
/// survives past the JNI call that handed the `View` to us.
|
||||||
|
///
|
||||||
|
/// **Goes through `View::post_delayed`, not `post_frame_callback`
|
||||||
|
/// directly** -- found the hard way (RUST.md's I5 Android integration):
|
||||||
|
/// `post_frame_callback`'s Java side calls `Choreographer.getInstance()`,
|
||||||
|
/// which throws `IllegalStateException` unless the *calling* thread already
|
||||||
|
/// has a `Looper` (`Choreographer.getInstance()`'s own contract). A tokio
|
||||||
|
/// worker thread, even freshly attached to the JVM, has none -- the crash
|
||||||
|
/// was a `JavaException` inside `View::post_frame_callback`'s `.unwrap()`,
|
||||||
|
/// aborting the process on the second `redraw.request_redraw()` any
|
||||||
|
/// android transcript-screen fetch made. `View.postDelayed(Runnable, 0)`
|
||||||
|
/// is the ordinary Android answer to "queue work onto a View's own UI
|
||||||
|
/// thread from any thread" and needs no Looper of its own; `delayed_callback`
|
||||||
|
/// below is what that Runnable resolves to on the UI thread, where a real
|
||||||
|
/// `post_frame_callback` is safe again.
|
||||||
|
pub struct AndroidRedrawHandle {
|
||||||
|
vm: JavaVM,
|
||||||
|
view: GlobalRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidRedrawHandle {
|
||||||
|
pub fn new(vm: JavaVM, view: GlobalRef) -> Self {
|
||||||
|
Self { vm, view }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestRedraw for AndroidRedrawHandle {
|
||||||
|
fn request_redraw(&self) {
|
||||||
|
let Ok(mut env) = self.vm.attach_current_thread() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let local = env.new_local_ref(&self.view).unwrap();
|
||||||
|
View(local).post_delayed(&mut env, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1080
File diff suppressed because it is too large.
Load diff
+231
@@ -0,0 +1,231 @@
|
|||||||
|
use crate::prelude::*;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// What focusing a text field takes from whichever backend is running --
|
||||||
|
/// tracked here rather than duplicated per backend, since `Selector` and
|
||||||
|
/// `Selectable` (below) are the *only* thing that decides which `TextEdit`
|
||||||
|
/// is the IME's target, and both platforms need the same double-click
|
||||||
|
/// timing and the same "remember which one" bookkeeping. What differs is
|
||||||
|
/// what happens *after* the focus record is set: winit tells the
|
||||||
|
/// compositor an IME area (`focus_gained`, in `default/attr.rs`); on
|
||||||
|
/// android-view a keyboard has to be asked for explicitly, and only from a
|
||||||
|
/// JNI call this crate cannot make outside a view callback -- so
|
||||||
|
/// `focus_gained` there (`android/attr.rs`) just raises a flag the next
|
||||||
|
/// touch callback consumes. See RUST.md's I2.
|
||||||
|
pub trait FocusHost {
|
||||||
|
/// True on a click close enough in time to the previous one to grow a
|
||||||
|
/// selection instead of starting a new one, updating the clock as a
|
||||||
|
/// side effect the way a real double-click timer does.
|
||||||
|
fn recent_click(&mut self) -> bool;
|
||||||
|
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>);
|
||||||
|
/// Called on every tap that should put the IME on `id`: the tap that
|
||||||
|
/// *makes* a `TextEdit` the focus target, and any later tap on one that
|
||||||
|
/// already is. `region` is where it was hit (`None` when the widget
|
||||||
|
/// could not be located, which happens for one it was just deselected
|
||||||
|
/// from). Implementations must be idempotent -- both backends' calls
|
||||||
|
/// (`showSoftInput`, `set_ime_cursor_area`) already are, which is what
|
||||||
|
/// lets the repeat tap be handled by the same call rather than by a
|
||||||
|
/// second "re-show" entry point beside it.
|
||||||
|
fn focus_gained(&mut self, region: Option<PixelRegion>);
|
||||||
|
/// Whether `id` is the current focus target -- what [`select`] uses to
|
||||||
|
/// tell a fresh press (which must wait to see whether it becomes a tap
|
||||||
|
/// or a drag before focusing/showing the IME, Iris 2026-09-06: "if I
|
||||||
|
/// swipe over the input bar it brings up the keyboard") from a drag
|
||||||
|
/// continuing inside a field that was already focused (an ordinary
|
||||||
|
/// drag-to-select, unaffected).
|
||||||
|
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper shared by every `FocusHost` impl, so the double-click window is
|
||||||
|
/// one constant rather than one per backend.
|
||||||
|
pub fn recent_click(last_click: &mut Instant) -> bool {
|
||||||
|
let now = Instant::now();
|
||||||
|
let recent = (now - *last_click) < Duration::from_millis(300);
|
||||||
|
*last_click = now;
|
||||||
|
recent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PressStart`/`Pressing`/`PressEnd`, all for the left button -- what
|
||||||
|
/// [`Selector`]/[`Selectable`] register instead of [`CursorSense::
|
||||||
|
/// click_or_drag`], so their shared handler (`on_press`, below) sees every
|
||||||
|
/// frame of a gesture and can tell a completed tap from a drag itself,
|
||||||
|
/// rather than reacting to `PressStart` alone the way `click_or_drag`'s
|
||||||
|
/// consumer used to (Iris, 2026-09-06: "if I swipe over the input bar it
|
||||||
|
/// brings up the keyboard").
|
||||||
|
/// `CursorSense::Cancel` is in the set for the same reason `DragGesture`
|
||||||
|
/// registers it: if a scroll area or a list takes the pointer mid-gesture,
|
||||||
|
/// this field sees no `PressEnd`, and a `press_origin` left set is then
|
||||||
|
/// compared against the *next* press -- a stray selection, or a keyboard
|
||||||
|
/// summoned by a tap somewhere else entirely.
|
||||||
|
fn press_track() -> CursorSenses {
|
||||||
|
CursorSense::click()
|
||||||
|
| CursorSense::Pressing(CursorButton::Left)
|
||||||
|
| CursorSense::unclick()
|
||||||
|
| CursorSense::Cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Selector;
|
||||||
|
|
||||||
|
impl<Rsc: HasEvents, W: Widget + 'static> WidgetAttr<Rsc, W> for Selector
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost,
|
||||||
|
{
|
||||||
|
type Input = WeakWidget<TextEdit>;
|
||||||
|
|
||||||
|
fn run(rsc: &mut Rsc, container: WeakWidget<W>, id: Self::Input) {
|
||||||
|
rsc.register_event(container, press_track(), move |ctx, rsc| {
|
||||||
|
let region = ctx.data.render.window_region(&id, &*rsc).unwrap();
|
||||||
|
let id_pos = region.top_left;
|
||||||
|
let container_pos = ctx
|
||||||
|
.data
|
||||||
|
.render
|
||||||
|
.window_region(&container, &*rsc)
|
||||||
|
.unwrap()
|
||||||
|
.top_left;
|
||||||
|
let pos = ctx.data.pos + container_pos - id_pos;
|
||||||
|
let size = region.size();
|
||||||
|
on_press(
|
||||||
|
rsc,
|
||||||
|
ctx.data.render,
|
||||||
|
ctx.state,
|
||||||
|
id,
|
||||||
|
pos,
|
||||||
|
size,
|
||||||
|
ctx.data.sense,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Selectable;
|
||||||
|
|
||||||
|
impl<Rsc: HasEvents> WidgetAttr<Rsc, TextEdit> for Selectable
|
||||||
|
where
|
||||||
|
Rsc::State: FocusHost,
|
||||||
|
{
|
||||||
|
type Input = ();
|
||||||
|
|
||||||
|
fn run(rsc: &mut Rsc, id: WeakWidget<TextEdit>, _: Self::Input) {
|
||||||
|
rsc.register_event(id, press_track(), move |ctx, rsc| {
|
||||||
|
on_press(
|
||||||
|
rsc,
|
||||||
|
ctx.data.render,
|
||||||
|
ctx.state,
|
||||||
|
id,
|
||||||
|
ctx.data.pos,
|
||||||
|
ctx.data.size,
|
||||||
|
ctx.data.sense,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One press-track frame (`PressStart`, `Pressing` or `PressEnd`) over a
|
||||||
|
/// selectable field. A field that is *already* focused behaves exactly as
|
||||||
|
/// `click_or_drag` always did -- every frame updates the selection, which
|
||||||
|
/// is what lets a finger already inside a focused field drag out a
|
||||||
|
/// selection. A field that is **not** focused withholds `select`'s
|
||||||
|
/// focus-granting side effects (and so the platform-specific `focus_gained`
|
||||||
|
/// that shows the keyboard) until the press resolves as a tap: `PressEnd`
|
||||||
|
/// with no frame in between having moved past [`DRAG_SLOP`] from where the
|
||||||
|
/// press began. A drag recognised before release simply cancels the
|
||||||
|
/// pending tap and does nothing further here -- it is not consumed, so
|
||||||
|
/// whatever is behind the field (a list to pan) still sees every frame of
|
||||||
|
/// it, the same as a drag that never touched a selectable field at all.
|
||||||
|
fn on_press(
|
||||||
|
rsc: &mut impl UiRsc,
|
||||||
|
render: &UiRenderState,
|
||||||
|
state: &mut impl FocusHost,
|
||||||
|
id: WeakWidget<TextEdit>,
|
||||||
|
pos: Vec2,
|
||||||
|
size: Vec2,
|
||||||
|
sense: CursorSense,
|
||||||
|
) {
|
||||||
|
if state.is_focused(id) {
|
||||||
|
// Already focused, so there is no keyboard to withhold -- but a
|
||||||
|
// vertical drag still is not a selection. Android's own `EditText`
|
||||||
|
// scrolls its overflowed text on a vertical drag and starts a
|
||||||
|
// selection only from a long press; a scroll area wrapping this
|
||||||
|
// field (`ScrollController::drag`) is what actually pans, and it needs the
|
||||||
|
// first frames of the gesture not to have selected anything behind
|
||||||
|
// it before it crosses `DRAG_SLOP` and takes pointer capture.
|
||||||
|
// `press_origin` carries the same meaning here as in the unfocused
|
||||||
|
// branch below -- "this gesture is still eligible", cleared the
|
||||||
|
// moment it becomes a drag -- so there is one flag, not two.
|
||||||
|
match sense {
|
||||||
|
CursorSense::PressStart(_) => {
|
||||||
|
let recent = state.recent_click();
|
||||||
|
id.edit(rsc).text.press_origin = Some(pos);
|
||||||
|
id.edit(rsc).select(pos, size, false, recent);
|
||||||
|
}
|
||||||
|
CursorSense::Pressing(_) | CursorSense::PressEnd(_) => {
|
||||||
|
let mut ctx = id.edit(rsc);
|
||||||
|
let Some(origin) = ctx.text.press_origin else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (dx, dy) = (pos.x - origin.x, pos.y - origin.y);
|
||||||
|
if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
|
||||||
|
ctx.text.press_origin = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ended = matches!(sense, CursorSense::PressEnd(_));
|
||||||
|
if ended {
|
||||||
|
ctx.text.press_origin = None;
|
||||||
|
}
|
||||||
|
ctx.select(pos, size, true, false);
|
||||||
|
// A tap on a field that is *already* focused asks for the
|
||||||
|
// keyboard again (Iris's phone, 2026-09-06: "I can't reopen
|
||||||
|
// keyboard by tapping on message box after it already
|
||||||
|
// happened once"). Dismissing the IME -- back gesture, or
|
||||||
|
// its own hide button -- takes the keyboard away but leaves
|
||||||
|
// the field focused, so without this the one branch that
|
||||||
|
// requests it (the unfocused one below) never runs again
|
||||||
|
// and the field is permanently unable to summon it.
|
||||||
|
// Android's own `EditText` does exactly this: every tap on
|
||||||
|
// a focused field calls `showSoftInput`, which is a no-op
|
||||||
|
// when the keyboard is already up.
|
||||||
|
//
|
||||||
|
// Gated on the same tap-vs-drag test the unfocused branch
|
||||||
|
// uses, not on `PressEnd` alone, so a drag-to-select that
|
||||||
|
// happens to finish inside the field does not summon a
|
||||||
|
// keyboard the reader was not asking for.
|
||||||
|
if ended && dx.abs() <= DRAG_SLOP && dy.abs() <= DRAG_SLOP {
|
||||||
|
state.focus_gained(render.window_region(&id, &*rsc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CursorSense::Cancel => id.edit(rsc).text.press_origin = None,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match sense {
|
||||||
|
CursorSense::PressStart(_) => {
|
||||||
|
id.edit(rsc).text.press_origin = Some(pos);
|
||||||
|
}
|
||||||
|
CursorSense::Pressing(_) => {
|
||||||
|
let ctx = id.edit(rsc);
|
||||||
|
if let Some(origin) = ctx.text.press_origin
|
||||||
|
&& ((pos.x - origin.x).abs() > DRAG_SLOP || (pos.y - origin.y).abs() > DRAG_SLOP)
|
||||||
|
{
|
||||||
|
// Past the slop before release: this is a drag, not a tap
|
||||||
|
// -- give up the pending focus rather than granting it once
|
||||||
|
// the finger lifts wherever it happens to be by then.
|
||||||
|
ctx.text.press_origin = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The gesture was taken by somebody else, so it is not a tap and
|
||||||
|
// must not grant focus when it ends out of this widget's sight.
|
||||||
|
CursorSense::Cancel => id.edit(rsc).text.press_origin = None,
|
||||||
|
CursorSense::PressEnd(_) => {
|
||||||
|
let was_tap = id.edit(rsc).text.press_origin.take().is_some();
|
||||||
|
if was_tap {
|
||||||
|
let recent = state.recent_click();
|
||||||
|
id.edit(rsc).select(pos, size, false, recent);
|
||||||
|
state.set_focus(Some(id));
|
||||||
|
state.focus_gained(render.window_region(&id, &*rsc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//! I4 (RUST.md): the desktop half of the AccessKit push, over
|
||||||
|
//! `accesskit_winit`. `bench-lib.sh`'s tap-by-name goes through the
|
||||||
|
//! platform's real accessibility tree, so this crate only has to keep that
|
||||||
|
//! tree in sync with `ui::access::AccessTree`'s output -- nothing here
|
||||||
|
//! reacts to an AccessKit action request, which is why the three handlers
|
||||||
|
//! below are inert. See RUST.md's I4 box for why: on Android (and, by the
|
||||||
|
//! same platform convention, everywhere else) a screen reader's element tap
|
||||||
|
//! is a real touch delivered at the node's own bounds, not an action
|
||||||
|
//! request synthesised in-process -- so the ordinary pointer path already
|
||||||
|
//! handles it once the bounds are right.
|
||||||
|
use accesskit::{ActionHandler, ActionRequest, ActivationHandler, DeactivationHandler, TreeUpdate};
|
||||||
|
|
||||||
|
pub struct NullActivationHandler;
|
||||||
|
impl ActivationHandler for NullActivationHandler {
|
||||||
|
fn request_initial_tree(&mut self) -> Option<TreeUpdate> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NullActionHandler;
|
||||||
|
impl ActionHandler for NullActionHandler {
|
||||||
|
fn do_action(&mut self, _request: ActionRequest) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NullDeactivationHandler;
|
||||||
|
impl DeactivationHandler for NullDeactivationHandler {
|
||||||
|
fn deactivate_accessibility(&mut self) {}
|
||||||
|
}
|
||||||
@@ -27,6 +27,10 @@ pub struct App<State: AppState> {
|
|||||||
|
|
||||||
impl<State: AppState> App<State> {
|
impl<State: AppState> App<State> {
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
// The desktop's `main` in everything but name -- see
|
||||||
|
// `super::logging`'s doc for why the logger goes here and what
|
||||||
|
// its absence hid.
|
||||||
|
super::logging::install(log::LevelFilter::Info);
|
||||||
let event_loop = EventLoop::with_user_event().build().unwrap();
|
let event_loop = EventLoop::with_user_event().build().unwrap();
|
||||||
let proxy = event_loop.create_proxy();
|
let proxy = event_loop.create_proxy();
|
||||||
event_loop
|
event_loop
|
||||||
|
|||||||
+17
-67
@@ -1,78 +1,28 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use std::time::{Duration, Instant};
|
use winit::dpi::{PhysicalPosition, PhysicalSize};
|
||||||
use winit::dpi::{LogicalPosition, LogicalSize};
|
|
||||||
|
|
||||||
pub struct Selector;
|
impl<T: HasDefaultUiState> FocusHost for T {
|
||||||
|
fn recent_click(&mut self) -> bool {
|
||||||
impl<Rsc: HasEvents, W: Widget + 'static> WidgetAttr<Rsc, W> for Selector
|
crate::attr::recent_click(&mut self.default_state_mut().last_click)
|
||||||
where
|
|
||||||
Rsc::State: HasDefaultUiState,
|
|
||||||
{
|
|
||||||
type Input = WeakWidget<TextEdit>;
|
|
||||||
|
|
||||||
fn run(rsc: &mut Rsc, container: WeakWidget<W>, id: Self::Input) {
|
|
||||||
rsc.register_event(container, CursorSense::click_or_drag(), move |ctx, rsc| {
|
|
||||||
let region = ctx.data.render.window_region(&id).unwrap();
|
|
||||||
let id_pos = region.top_left;
|
|
||||||
let container_pos = ctx.data.render.window_region(&container).unwrap().top_left;
|
|
||||||
let pos = ctx.data.pos + container_pos - id_pos;
|
|
||||||
let size = region.size();
|
|
||||||
select(
|
|
||||||
rsc,
|
|
||||||
ctx.data.render,
|
|
||||||
ctx.state,
|
|
||||||
id,
|
|
||||||
pos,
|
|
||||||
size,
|
|
||||||
ctx.data.sense.is_dragging(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Selectable;
|
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
|
||||||
|
self.default_state_mut().focus = id;
|
||||||
impl<Rsc: HasEvents> WidgetAttr<Rsc, TextEdit> for Selectable
|
|
||||||
where
|
|
||||||
Rsc::State: HasDefaultUiState,
|
|
||||||
{
|
|
||||||
type Input = ();
|
|
||||||
|
|
||||||
fn run(rsc: &mut Rsc, id: WeakWidget<TextEdit>, _: Self::Input) {
|
|
||||||
rsc.register_event(id, CursorSense::click_or_drag(), move |ctx, rsc| {
|
|
||||||
select(
|
|
||||||
rsc,
|
|
||||||
ctx.data.render,
|
|
||||||
ctx.state,
|
|
||||||
id,
|
|
||||||
ctx.data.pos,
|
|
||||||
ctx.data.size,
|
|
||||||
ctx.data.sense.is_dragging(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn select(
|
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||||
rsc: &mut impl UiRsc,
|
self.default_state().focus == Some(id)
|
||||||
render: &UiRenderState,
|
}
|
||||||
state: &mut impl HasDefaultUiState,
|
|
||||||
id: WeakWidget<TextEdit>,
|
fn focus_gained(&mut self, region: Option<PixelRegion>) {
|
||||||
pos: Vec2,
|
let state = self.default_state_mut();
|
||||||
size: Vec2,
|
let Some(region) = region else { return };
|
||||||
dragging: bool,
|
|
||||||
) {
|
|
||||||
let state = state.default_state_mut();
|
|
||||||
let now = Instant::now();
|
|
||||||
let recent = (now - state.last_click) < Duration::from_millis(300);
|
|
||||||
state.last_click = now;
|
|
||||||
id.edit(rsc).select(pos, size, dragging, recent);
|
|
||||||
if let Some(region) = render.window_region(&id) {
|
|
||||||
state.window.set_ime_allowed(true);
|
state.window.set_ime_allowed(true);
|
||||||
|
// Physical, like everything else this backend hands winit --
|
||||||
|
// `default::content_scale`.
|
||||||
state.window.set_ime_cursor_area(
|
state.window.set_ime_cursor_area(
|
||||||
LogicalPosition::<f32>::from(region.top_left.tuple()),
|
PhysicalPosition::<f32>::from(region.top_left.tuple()),
|
||||||
LogicalSize::<f32>::from(region.size().tuple()),
|
PhysicalSize::<f32>::from(region.size().tuple()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
state.focus = Some(id);
|
|
||||||
}
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
use iris_core::Event;
|
|
||||||
|
|
||||||
#[derive(Eq, PartialEq, Hash, Clone)]
|
|
||||||
pub struct Submit;
|
|
||||||
impl Event for Submit {}
|
|
||||||
|
|
||||||
#[derive(Eq, PartialEq, Hash, Clone)]
|
|
||||||
pub struct Edited;
|
|
||||||
impl Event for Edited {}
|
|
||||||
+17
-1
@@ -1,4 +1,10 @@
|
|||||||
|
// `CursorState::time` is the sample's own time on every backend. winit
|
||||||
|
// carries no timestamp on a pointer event, so the moment it is handed to
|
||||||
|
// us is the closest measurement available here -- which is also what the
|
||||||
|
// drag code used to do for itself with `Instant::now()`, before Android's
|
||||||
|
// batched samples made the difference matter (see `sense::CursorState`).
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
use std::time::Instant;
|
||||||
use winit::{
|
use winit::{
|
||||||
event::{MouseButton, MouseScrollDelta, WindowEvent},
|
event::{MouseButton, MouseScrollDelta, WindowEvent},
|
||||||
keyboard::{Key, NamedKey},
|
keyboard::{Key, NamedKey},
|
||||||
@@ -11,13 +17,19 @@ pub struct Input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Input {
|
impl Input {
|
||||||
|
/// winit's pointer coordinates are physical pixels, which is the
|
||||||
|
/// space the whole tree is laid out and hit-tested in -- see
|
||||||
|
/// `default::content_scale`. Nothing is converted here; `dp(...)`
|
||||||
|
/// resolves against the density at layout time instead.
|
||||||
pub fn event(&mut self, event: &WindowEvent) -> bool {
|
pub fn event(&mut self, event: &WindowEvent) -> bool {
|
||||||
match event {
|
match event {
|
||||||
WindowEvent::CursorMoved { position, .. } => {
|
WindowEvent::CursorMoved { position, .. } => {
|
||||||
self.cursor.pos = Vec2::new(position.x as f32, position.y as f32);
|
self.cursor.pos = Vec2::new(position.x as f32, position.y as f32);
|
||||||
self.cursor.exists = true;
|
self.cursor.exists = true;
|
||||||
|
self.cursor.time = Instant::now();
|
||||||
}
|
}
|
||||||
WindowEvent::MouseInput { state, button, .. } => {
|
WindowEvent::MouseInput { state, button, .. } => {
|
||||||
|
self.cursor.time = Instant::now();
|
||||||
let buttons = &mut self.cursor.buttons;
|
let buttons = &mut self.cursor.buttons;
|
||||||
let pressed = state.is_pressed();
|
let pressed = state.is_pressed();
|
||||||
match button {
|
match button {
|
||||||
@@ -37,6 +49,7 @@ impl Input {
|
|||||||
delta.y = 0.0;
|
delta.y = 0.0;
|
||||||
}
|
}
|
||||||
self.cursor.scroll_delta = delta;
|
self.cursor.scroll_delta = delta;
|
||||||
|
self.cursor.time = Instant::now();
|
||||||
}
|
}
|
||||||
WindowEvent::CursorLeft { .. } => {
|
WindowEvent::CursorLeft { .. } => {
|
||||||
self.cursor.exists = false;
|
self.cursor.exists = false;
|
||||||
@@ -67,9 +80,12 @@ impl Input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DefaultUiState {
|
impl DefaultUiState {
|
||||||
|
/// Physical pixels, matching `WindowEvent::Resized` (what
|
||||||
|
/// `UiRenderState::resize` is given) and the swapchain -- see
|
||||||
|
/// `default::content_scale`.
|
||||||
pub fn window_size(&self) -> Vec2 {
|
pub fn window_size(&self) -> Vec2 {
|
||||||
let size = self.renderer.window().inner_size();
|
let size = self.renderer.window().inner_size();
|
||||||
(size.width, size.height).into()
|
Vec2::new(size.width as f32, size.height as f32)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cursor_state(&self) -> &CursorState {
|
pub fn cursor_state(&self) -> &CursorState {
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
//! A stderr logger for the desktop entry point.
|
||||||
|
//!
|
||||||
|
//! Without one, `log::` calls on this side go nowhere: `log`'s default is
|
||||||
|
//! a no-op logger, and nothing in `desktop-app` or the examples ever
|
||||||
|
//! installed a real one. That is how iris came to have a renderer that
|
||||||
|
//! silently fell back to GLES (and, on this VM, on to llvmpipe when the
|
||||||
|
//! host took its GPU away) with **no record anywhere of what
|
||||||
|
//! drew the frame** -- a layer-2 screenshot off llvmpipe and one off the
|
||||||
|
//! host GPU are the same PNG, and the difference is exactly what a
|
||||||
|
//! screenshot is being taken to judge.
|
||||||
|
//!
|
||||||
|
//! Installed by [`DefaultApp::run`](super::app::DefaultApp::run) rather
|
||||||
|
//! than by a library call somewhere, because that function already takes
|
||||||
|
//! over the process -- it owns the event loop and does not return -- so
|
||||||
|
//! it is the desktop's `main` in everything but name, and one install
|
||||||
|
//! there covers `desktop-app` and every example at once. `try_init`
|
||||||
|
//! rather than `init`: a binary that installed its own logger first keeps
|
||||||
|
//! it, and a second `DefaultApp::run` in one process is not an error.
|
||||||
|
//!
|
||||||
|
//! Deliberately not `env_logger`. All this owes the reader is a level and
|
||||||
|
//! a line, which is a page of code against a dependency plus its own
|
||||||
|
//! filter dialect; the Android side is `android_logger` for the same
|
||||||
|
//! reason -- one line per platform's own convention.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use log::{Level, LevelFilter, Log, Metadata, Record};
|
||||||
|
|
||||||
|
/// Reads one level name from `RUST_LOG` -- `off`, `error`, `warn`,
|
||||||
|
/// `info`, `debug`, `trace`, case-insensitively. **Not env_logger's
|
||||||
|
/// per-module filter syntax**: anything else is ignored and the default
|
||||||
|
/// stands, rather than being silently read as "off", since a typo that
|
||||||
|
/// turned logging off would be indistinguishable from a quiet program.
|
||||||
|
fn level_from_env(default: LevelFilter) -> LevelFilter {
|
||||||
|
match std::env::var("RUST_LOG") {
|
||||||
|
Ok(text) => text.trim().parse().unwrap_or(default),
|
||||||
|
Err(_) => default,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StderrLogger {
|
||||||
|
level: LevelFilter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Log for StderrLogger {
|
||||||
|
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||||
|
metadata.level() <= self.level
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self, record: &Record) {
|
||||||
|
if !self.enabled(record.metadata()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// One write, not a `writeln!` per part: two threads logging at
|
||||||
|
// once interleave otherwise, and the frame and input traces are
|
||||||
|
// both written from whichever thread produced them.
|
||||||
|
let line = format!(
|
||||||
|
"{level:<5} {target}: {args}\n",
|
||||||
|
level = match record.level() {
|
||||||
|
Level::Error => "ERROR",
|
||||||
|
Level::Warn => "WARN",
|
||||||
|
Level::Info => "INFO",
|
||||||
|
Level::Debug => "DEBUG",
|
||||||
|
Level::Trace => "TRACE",
|
||||||
|
},
|
||||||
|
target = record.target(),
|
||||||
|
args = record.args(),
|
||||||
|
);
|
||||||
|
let _ = std::io::stderr().write_all(line.as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self) {
|
||||||
|
let _ = std::io::stderr().flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs the stderr logger unless this process already has one.
|
||||||
|
/// Defaults to `info`, which is where the renderer says which adapter it
|
||||||
|
/// got; `RUST_LOG=debug` adds iris's own per-frame lines.
|
||||||
|
pub fn install(default: LevelFilter) {
|
||||||
|
let level = level_from_env(default);
|
||||||
|
let logger = Box::leak(Box::new(StderrLogger { level }));
|
||||||
|
if log::set_logger(logger).is_ok() {
|
||||||
|
log::set_max_level(level);
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
-43
@@ -11,26 +11,53 @@ use winit::{
|
|||||||
window::{Window, WindowAttributes},
|
window::{Window, WindowAttributes},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod access;
|
||||||
mod app;
|
mod app;
|
||||||
mod attr;
|
mod attr;
|
||||||
mod event;
|
|
||||||
mod input;
|
mod input;
|
||||||
|
mod logging;
|
||||||
|
mod platform;
|
||||||
mod render;
|
mod render;
|
||||||
mod sense;
|
|
||||||
mod state;
|
|
||||||
mod task;
|
|
||||||
|
|
||||||
|
pub use access::*;
|
||||||
pub use app::*;
|
pub use app::*;
|
||||||
pub use attr::*;
|
|
||||||
pub use event::*;
|
|
||||||
pub use input::*;
|
pub use input::*;
|
||||||
pub use render::*;
|
pub use render::*;
|
||||||
pub use sense::*;
|
|
||||||
pub use state::*;
|
|
||||||
pub use task::*;
|
|
||||||
|
|
||||||
pub type Proxy<Event> = EventLoopProxy<Event>;
|
pub type Proxy<Event> = EventLoopProxy<Event>;
|
||||||
|
|
||||||
|
/// The desktop's `content_scale`: physical pixels per dp, the same
|
||||||
|
/// quantity Android reads from `DisplayMetrics.density` and feeds to
|
||||||
|
/// `UiRenderState::set_density` (`android::view::AndroidUiState::
|
||||||
|
/// content_scale`'s field comment). Everything in this backend is
|
||||||
|
/// physical pixels -- the window size, the pointer, the widget tree --
|
||||||
|
/// and `dp(...)` is what resolves against this at layout time, exactly
|
||||||
|
/// as on the phone. That is a correction from an earlier version that
|
||||||
|
/// divided winit's coordinates into a separate "logical" space instead:
|
||||||
|
/// it left `UiRenderState::resize` (physical, from `WindowEvent::
|
||||||
|
/// Resized`) and the window uniform (logical) disagreeing on any
|
||||||
|
/// display whose scale factor is not 1.0, and it rasterised glyphs at
|
||||||
|
/// one resolution to display them at another -- the blur the phone's own
|
||||||
|
/// stopgap produced before `dp` existed.
|
||||||
|
///
|
||||||
|
/// **`IRIS_SCALE` overrides it**, which is how a phone-shaped desktop
|
||||||
|
/// window runs the phone's density (`run-headless.sh --phone`,
|
||||||
|
/// docs/RUST.md's layer 2). An unparsable value is a typo in a command
|
||||||
|
/// somebody just typed, so it says so and uses the window's own answer
|
||||||
|
/// rather than silently laying out at the wrong density.
|
||||||
|
pub fn content_scale(window: &Window) -> f32 {
|
||||||
|
match std::env::var("IRIS_SCALE") {
|
||||||
|
Err(_) => window.scale_factor() as f32,
|
||||||
|
Ok(text) => match text.trim().parse::<f32>() {
|
||||||
|
Ok(scale) if scale > 0.0 => scale,
|
||||||
|
_ => {
|
||||||
|
log::warn!("IRIS_SCALE={text:?} is not a positive number; using the window's own");
|
||||||
|
window.scale_factor() as f32
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct DefaultUiState {
|
pub struct DefaultUiState {
|
||||||
pub root: Option<StrongWidget>,
|
pub root: Option<StrongWidget>,
|
||||||
pub renderer: UiRenderer,
|
pub renderer: UiRenderer,
|
||||||
@@ -40,6 +67,17 @@ pub struct DefaultUiState {
|
|||||||
pub window: Arc<Window>,
|
pub window: Arc<Window>,
|
||||||
pub ime: usize,
|
pub ime: usize,
|
||||||
pub last_click: Instant,
|
pub last_click: Instant,
|
||||||
|
/// I4 (RUST.md): pushed through in `DefaultApp::window_event`'s
|
||||||
|
/// `RedrawRequested` arm, from `access`'s output. Built in
|
||||||
|
/// `DefaultApp::new`, which is the only place with the
|
||||||
|
/// `&ActiveEventLoop` `accesskit_winit::Adapter::with_direct_handlers`
|
||||||
|
/// needs -- see that constructor's doc comment on why the window must
|
||||||
|
/// still be invisible when it is called.
|
||||||
|
pub access_adapter: accesskit_winit::Adapter,
|
||||||
|
/// The AccessKit tree itself -- see `iris_core::AccessTree`'s doc
|
||||||
|
/// comment for the flat shape and why it only rebuilds on a real
|
||||||
|
/// change.
|
||||||
|
pub access: AccessTree,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HasRoot for DefaultUiState {
|
impl HasRoot for DefaultUiState {
|
||||||
@@ -49,7 +87,7 @@ impl HasRoot for DefaultUiState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DefaultUiState {
|
impl DefaultUiState {
|
||||||
pub fn new(window: impl Into<Arc<Window>>) -> Self {
|
pub fn new(window: impl Into<Arc<Window>>, access_adapter: accesskit_winit::Adapter) -> Self {
|
||||||
let window = window.into();
|
let window = window.into();
|
||||||
Self {
|
Self {
|
||||||
root: None,
|
root: None,
|
||||||
@@ -60,6 +98,8 @@ impl DefaultUiState {
|
|||||||
ime: 0,
|
ime: 0,
|
||||||
last_click: Instant::now(),
|
last_click: Instant::now(),
|
||||||
focus: None,
|
focus: None,
|
||||||
|
access_adapter,
|
||||||
|
access: AccessTree::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,13 +228,35 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
|||||||
type Event = State::Event;
|
type Event = State::Event;
|
||||||
|
|
||||||
fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy<Self::Event>) -> Self {
|
fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy<Self::Event>) -> Self {
|
||||||
|
// `accesskit_winit::Adapter::with_direct_handlers` panics if the
|
||||||
|
// window is already visible when it's built, so the window is
|
||||||
|
// created hidden and only shown once the adapter exists -- the one
|
||||||
|
// extra step I4 (RUST.md) needs here. The three handlers are inert
|
||||||
|
// (see `access.rs`): a screen reader's tap is a real touch at the
|
||||||
|
// node's bounds, not an action request this process has to answer.
|
||||||
let window = event_loop
|
let window = event_loop
|
||||||
.create_window(State::window_attributes())
|
.create_window(State::window_attributes().with_visible(false))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let default_state = DefaultUiState::new(window);
|
let access_adapter = accesskit_winit::Adapter::with_direct_handlers(
|
||||||
|
event_loop,
|
||||||
|
&window,
|
||||||
|
NullActivationHandler,
|
||||||
|
NullActionHandler,
|
||||||
|
NullDeactivationHandler,
|
||||||
|
);
|
||||||
|
window.set_visible(true);
|
||||||
|
let default_state = DefaultUiState::new(window, access_adapter);
|
||||||
let (mut rsc, task_recv) = DefaultRsc::init(default_state.window.clone());
|
let (mut rsc, task_recv) = DefaultRsc::init(default_state.window.clone());
|
||||||
|
// Both copies of the density, set before the first widget is
|
||||||
|
// built so text shapes at the right size on the opening frame --
|
||||||
|
// the same pair `android::view::new_peer` sets from
|
||||||
|
// `content_scale`. See `iris_core::TextData::density` for why the
|
||||||
|
// shaper keeps its own.
|
||||||
|
let scale = content_scale(default_state.window.as_ref());
|
||||||
|
rsc.ui.text.density = scale;
|
||||||
let state = State::new(default_state, &mut rsc, proxy);
|
let state = State::new(default_state, &mut rsc, proxy);
|
||||||
let render = UiRenderState::new();
|
let mut render = UiRenderState::new();
|
||||||
|
render.set_density(scale);
|
||||||
Self {
|
Self {
|
||||||
rsc,
|
rsc,
|
||||||
state,
|
state,
|
||||||
@@ -220,6 +282,12 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ui_state = state.default_state_mut();
|
let ui_state = state.default_state_mut();
|
||||||
|
// Required by `accesskit_winit` on every window event, not just the
|
||||||
|
// ones this backend otherwise cares about -- some platform adapters
|
||||||
|
// rely on it to notice activation (a screen reader turning on).
|
||||||
|
ui_state
|
||||||
|
.access_adapter
|
||||||
|
.process_event(&ui_state.window, &event);
|
||||||
let input_changed = ui_state.input.event(&event);
|
let input_changed = ui_state.input.event(&event);
|
||||||
let cursor_state = ui_state.cursor_state().clone();
|
let cursor_state = ui_state.cursor_state().clone();
|
||||||
let old = ui_state.focus;
|
let old = ui_state.focus;
|
||||||
@@ -227,6 +295,31 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
|||||||
ui_state.focus = None;
|
ui_state.focus = None;
|
||||||
}
|
}
|
||||||
if input_changed {
|
if input_changed {
|
||||||
|
// The winit half of `iris::input` (`sense::log_input_event`'s
|
||||||
|
// own doc): no batching here, so `historical` is always empty
|
||||||
|
// -- winit hands one `WindowEvent` per pointer sample, unlike
|
||||||
|
// Android's `MotionEvent`. The action is read back off the
|
||||||
|
// buttons `Input::event` just updated, the same test
|
||||||
|
// `GestureOutcome`'s callers already use to tell a press from a
|
||||||
|
// release. Computed only when tracing is on, same reasoning as
|
||||||
|
// `log_input_event` itself gating on it.
|
||||||
|
if crate::diagnostics::trace_enabled() {
|
||||||
|
let action = if cursor_state.buttons.left.is_start() {
|
||||||
|
"down"
|
||||||
|
} else if cursor_state.buttons.left.is_end() {
|
||||||
|
"up"
|
||||||
|
} else {
|
||||||
|
"move"
|
||||||
|
};
|
||||||
|
let t_ms = cursor_state.time.duration_since(render.epoch()).as_millis() as u64;
|
||||||
|
crate::sense::log_input_event(
|
||||||
|
action,
|
||||||
|
cursor_state.pos.x,
|
||||||
|
cursor_state.pos.y,
|
||||||
|
t_ms,
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
}
|
||||||
let window_size = ui_state.window_size();
|
let window_size = ui_state.window_size();
|
||||||
render.run_sensors(rsc, state, cursor_state, window_size);
|
render.run_sensors(rsc, state, cursor_state, window_size);
|
||||||
}
|
}
|
||||||
@@ -239,14 +332,53 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
|||||||
match &event {
|
match &event {
|
||||||
WindowEvent::CloseRequested => event_loop.exit(),
|
WindowEvent::CloseRequested => event_loop.exit(),
|
||||||
WindowEvent::RedrawRequested => {
|
WindowEvent::RedrawRequested => {
|
||||||
|
// Before the draw, so this frame shows this instant's
|
||||||
|
// position (`UiData::tick_animations`' own doc), and the
|
||||||
|
// window is asked for another frame while anything is
|
||||||
|
// still moving -- the winit half of what
|
||||||
|
// `IrisViewPeer::render`'s `post_frame_callback` does on
|
||||||
|
// Android. Nothing else in iris moves without an input
|
||||||
|
// event.
|
||||||
|
let frame_start = std::time::Instant::now();
|
||||||
|
let animating = rsc.ui_mut().tick_animations(frame_start);
|
||||||
|
let ui_state = state.default_state_mut();
|
||||||
render.update(&ui_state.root, rsc);
|
render.update(&ui_state.root, rsc);
|
||||||
ui_state.renderer.update(&mut rsc.ui, render);
|
ui_state.renderer.update(&mut rsc.ui, render);
|
||||||
|
let draw_start = std::time::Instant::now();
|
||||||
ui_state.renderer.draw();
|
ui_state.renderer.draw();
|
||||||
|
crate::diagnostics::log_frame(render, frame_start, draw_start.elapsed(), animating);
|
||||||
|
if animating {
|
||||||
|
ui_state.window.request_redraw();
|
||||||
|
}
|
||||||
|
// I4 (RUST.md): only produces a `TreeUpdate` when the named
|
||||||
|
// set actually changed this frame -- see `AccessTree`'s doc
|
||||||
|
// comment. `render` reflects the draw that just happened,
|
||||||
|
// so `resolved_region`/`window_region` inside it report a
|
||||||
|
// moved subtree's *new* position, not last frame's.
|
||||||
|
if let Some(tree_update) = ui_state.access.update(rsc.widgets(), render, rsc) {
|
||||||
|
ui_state.access_adapter.update_if_active(|| tree_update);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
WindowEvent::Resized(size) => {
|
WindowEvent::Resized(size) => {
|
||||||
render.resize((size.width, size.height));
|
render.resize((size.width, size.height));
|
||||||
ui_state.renderer.resize(size)
|
ui_state.renderer.resize(size)
|
||||||
}
|
}
|
||||||
|
// Dragging the window to a display with a different scale.
|
||||||
|
// Both copies again, the pair `new` sets at startup -- read
|
||||||
|
// through `content_scale` rather than from the event, so
|
||||||
|
// `IRIS_SCALE` still pins the density it was given (the
|
||||||
|
// `--phone` window must not follow the monitor). winit sends
|
||||||
|
// the matching `Resized` separately. Before 2026-09-07 this
|
||||||
|
// event was unhandled, so every `dp` and every rasterised
|
||||||
|
// glyph stayed at the density the window opened on
|
||||||
|
// (docs/REVIEW-2026-09-07.md's R5) -- invisible on this
|
||||||
|
// machine, where every display is 1.0.
|
||||||
|
WindowEvent::ScaleFactorChanged { .. } => {
|
||||||
|
let scale = content_scale(ui_state.window.as_ref());
|
||||||
|
rsc.ui.text.density = scale;
|
||||||
|
render.set_density(scale);
|
||||||
|
ui_state.window.request_redraw();
|
||||||
|
}
|
||||||
WindowEvent::KeyboardInput { event, .. } => {
|
WindowEvent::KeyboardInput { event, .. } => {
|
||||||
if let Some(sel) = ui_state.focus
|
if let Some(sel) = ui_state.focus
|
||||||
&& event.state.is_pressed()
|
&& event.state.is_pressed()
|
||||||
@@ -309,12 +441,6 @@ impl<State: DefaultAppState> AppState for DefaultApp<State> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait RscIdx<Rsc> {
|
|
||||||
type Output;
|
|
||||||
fn get(self, rsc: &Rsc) -> &Self::Output;
|
|
||||||
fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<State: 'static, I: RscIdx<DefaultRsc<State>>> std::ops::Index<I> for DefaultRsc<State> {
|
impl<State: 'static, I: RscIdx<DefaultRsc<State>>> std::ops::Index<I> for DefaultRsc<State> {
|
||||||
type Output = I::Output;
|
type Output = I::Output;
|
||||||
|
|
||||||
@@ -328,27 +454,3 @@ impl<State: 'static, I: RscIdx<DefaultRsc<State>>> std::ops::IndexMut<I> for Def
|
|||||||
index.get_mut(self)
|
index.get_mut(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<W: Widget, Rsc: UiRsc> RscIdx<Rsc> for WeakWidget<W> {
|
|
||||||
type Output = W;
|
|
||||||
|
|
||||||
fn get(self, rsc: &Rsc) -> &Self::Output {
|
|
||||||
&rsc.ui().widgets[self]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output {
|
|
||||||
&mut rsc.ui_mut().widgets[self]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: 'static, Rsc: HasWidgetState> RscIdx<Rsc> for WeakState<T> {
|
|
||||||
type Output = T;
|
|
||||||
|
|
||||||
fn get(self, rsc: &Rsc) -> &Self::Output {
|
|
||||||
rsc.widget_state().get(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output {
|
|
||||||
rsc.widget_state_mut().get_mut(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
use crate::platform::OpenUrl;
|
||||||
|
use crate::prelude::HasDefaultUiState;
|
||||||
|
|
||||||
|
/// The desktop's URL opener: the platform's own "open this with whatever
|
||||||
|
/// is registered for it" command, detached so a browser starting slowly
|
||||||
|
/// cannot stall the event loop.
|
||||||
|
///
|
||||||
|
/// A command rather than a crate: `xdg-open`/`open`/`start` is what every
|
||||||
|
/// such crate shells out to anyway, and this is one call site.
|
||||||
|
impl<T: HasDefaultUiState> OpenUrl for T {
|
||||||
|
fn open_url(&mut self, url: &str) {
|
||||||
|
let (program, first): (&str, &[&str]) = if cfg!(target_os = "macos") {
|
||||||
|
("open", &[])
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
// `start` is a shell builtin, and its first argument is the
|
||||||
|
// window title -- an empty one, or a URL containing `&` ends
|
||||||
|
// up split.
|
||||||
|
("cmd", &["/C", "start", ""])
|
||||||
|
} else {
|
||||||
|
("xdg-open", &[])
|
||||||
|
};
|
||||||
|
match std::process::Command::new(program)
|
||||||
|
.args(first)
|
||||||
|
.arg(url)
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(_) => {}
|
||||||
|
// Named with the command that failed and the link it was for,
|
||||||
|
// since neither is recoverable from the OS error alone.
|
||||||
|
Err(e) => log::warn!("could not open {url} with {program}: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+125
-22
@@ -1,4 +1,5 @@
|
|||||||
use iris_core::{UiData, UiLimits, UiRenderNode, UiRenderState};
|
use crate::task::RequestRedraw;
|
||||||
|
use iris_core::{UiData, UiRenderNode, UiRenderState, util::Vec2};
|
||||||
use pollster::FutureExt;
|
use pollster::FutureExt;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use wgpu::*;
|
use wgpu::*;
|
||||||
@@ -6,6 +7,12 @@ use winit::{dpi::PhysicalSize, window::Window};
|
|||||||
|
|
||||||
pub const CLEAR_COLOR: Color = Color::BLACK;
|
pub const CLEAR_COLOR: Color = Color::BLACK;
|
||||||
|
|
||||||
|
impl RequestRedraw for Window {
|
||||||
|
fn request_redraw(&self) {
|
||||||
|
Window::request_redraw(self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct UiRenderer {
|
pub struct UiRenderer {
|
||||||
window: Arc<Window>,
|
window: Arc<Window>,
|
||||||
surface: Surface<'static>,
|
surface: Surface<'static>,
|
||||||
@@ -22,7 +29,16 @@ impl UiRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw(&mut self) {
|
pub fn draw(&mut self) {
|
||||||
let output = self.surface.get_current_texture().unwrap();
|
let output = match self.surface.get_current_texture() {
|
||||||
|
CurrentSurfaceTexture::Success(texture)
|
||||||
|
| CurrentSurfaceTexture::Suboptimal(texture) => texture,
|
||||||
|
// wgpu 30 turned this Result into an enum; every arm here was an
|
||||||
|
// `Err` the previous `.unwrap()` panicked on, except `Occluded`,
|
||||||
|
// which is new. Named rather than swallowed: a window that stops
|
||||||
|
// presenting silently is the state this file's `pre_present_notify`
|
||||||
|
// comment was written about.
|
||||||
|
other => panic!("no surface texture to draw into: {other:?}"),
|
||||||
|
};
|
||||||
let view = output
|
let view = output
|
||||||
.texture
|
.texture
|
||||||
.create_view(&TextureViewDescriptor::default());
|
.create_view(&TextureViewDescriptor::default());
|
||||||
@@ -45,14 +61,25 @@ impl UiRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.queue.submit(std::iter::once(encoder.finish()));
|
self.queue.submit(std::iter::once(encoder.finish()));
|
||||||
output.present();
|
// Immediately before presenting, so the windowing system can schedule
|
||||||
|
// the frame. On Wayland this is what ties the commit to the surface's
|
||||||
|
// frame callback; without it a frame drawn when nothing else follows
|
||||||
|
// could sit unpresented, and the window kept the layout it had before
|
||||||
|
// the compositor's first resize -- intermittently, on about a fifth of
|
||||||
|
// starts, with nothing left to flush it.
|
||||||
|
self.window.pre_present_notify();
|
||||||
|
self.queue.present(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resize(&mut self, size: &PhysicalSize<u32>) {
|
pub fn resize(&mut self, size: &PhysicalSize<u32>) {
|
||||||
self.config.width = size.width;
|
self.config.width = size.width;
|
||||||
self.config.height = size.height;
|
self.config.height = size.height;
|
||||||
self.surface.configure(&self.device, &self.config);
|
self.surface.configure(&self.device, &self.config);
|
||||||
self.ui.resize(size, &self.queue);
|
// Physical, matching `new`'s own seed -- see the comment there.
|
||||||
|
self.ui.resize(
|
||||||
|
Vec2::new(size.width as f32, size.height as f32),
|
||||||
|
&self.queue,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_encoder(device: &Device) -> CommandEncoder {
|
fn create_encoder(device: &Device) -> CommandEncoder {
|
||||||
@@ -64,10 +91,44 @@ impl UiRenderer {
|
|||||||
pub fn new(window: Arc<Window>) -> Self {
|
pub fn new(window: Arc<Window>) -> Self {
|
||||||
let size = window.inner_size();
|
let size = window.inner_size();
|
||||||
|
|
||||||
let instance = Instance::new(&InstanceDescriptor {
|
// `force-gles` on the desktop too, not just on Android: the
|
||||||
backends: Backends::PRIMARY,
|
// GLES backend has behaviour of its own (a one-layer array
|
||||||
..Default::default()
|
// texture is a `GL_TEXTURE_2D` -- see
|
||||||
|
// `GpuTextures::create_array_texture`), and a machine with a
|
||||||
|
// real GPU is where that is cheap to reproduce and screenshot.
|
||||||
|
let mut backends = if cfg!(feature = "force-gles") {
|
||||||
|
Backends::GL
|
||||||
|
} else {
|
||||||
|
Backends::PRIMARY
|
||||||
|
};
|
||||||
|
// The display handle comes from the window rather than being left
|
||||||
|
// out: wgpu 30 asks for it whenever a GLES surface is going to be
|
||||||
|
// presented on Wayland, which is exactly what the fallback below
|
||||||
|
// produces on this machine.
|
||||||
|
let mut instance = Instance::new(InstanceDescriptor {
|
||||||
|
backends,
|
||||||
|
..InstanceDescriptor::new_with_display_handle(Box::new(window.clone()))
|
||||||
});
|
});
|
||||||
|
// The same fallback the Android backend grew in 85869d0, and for
|
||||||
|
// the same reason: a machine can advertise a Vulkan ICD with no
|
||||||
|
// device behind it, and refusing to draw at all because the only
|
||||||
|
// usable adapter is a GLES one is iris's bug rather than the
|
||||||
|
// machine's. On this VM the Vulkan device disappears whenever
|
||||||
|
// the host refuses a virtio-gpu context, so `run-headless.sh` --
|
||||||
|
// layer 2 of the test rig -- aborted with `Could not get
|
||||||
|
// adapter!` while GL was sitting there working. Probed before the
|
||||||
|
// surface exists, matching Android, where an instance carrying
|
||||||
|
// both backends fails worse than one carrying the wrong one.
|
||||||
|
if backends != Backends::GL && instance.enumerate_adapters(backends).block_on().is_empty() {
|
||||||
|
log::warn!(
|
||||||
|
"iris renderer: no {backends:?} adapter on this machine, falling back to GLES"
|
||||||
|
);
|
||||||
|
backends = Backends::GL;
|
||||||
|
instance = Instance::new(InstanceDescriptor {
|
||||||
|
backends,
|
||||||
|
..InstanceDescriptor::new_with_display_handle(Box::new(window.clone()))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let surface = instance
|
let surface = instance
|
||||||
.create_surface(window.clone())
|
.create_surface(window.clone())
|
||||||
@@ -78,25 +139,48 @@ impl UiRenderer {
|
|||||||
power_preference: PowerPreference::default(),
|
power_preference: PowerPreference::default(),
|
||||||
compatible_surface: Some(&surface),
|
compatible_surface: Some(&surface),
|
||||||
force_fallback_adapter: false,
|
force_fallback_adapter: false,
|
||||||
|
..Default::default()
|
||||||
})
|
})
|
||||||
.block_on()
|
.block_on()
|
||||||
.expect("Could not get adapter!");
|
.unwrap_or_else(|error| {
|
||||||
|
panic!("No usable GPU adapter for backends {backends:?}: {error}")
|
||||||
|
});
|
||||||
|
|
||||||
let ui_limits = UiLimits::default();
|
// Say which adapter won, in the same words the Android backend
|
||||||
|
// uses. Without it a layer-2 screenshot or frame time from this
|
||||||
|
// window carries no record of what drew it, and the two cases that
|
||||||
|
// matter look identical in the PNG: the host's real GPU, and
|
||||||
|
// llvmpipe after this VM lost its virtio-gpu contexts. That
|
||||||
|
// happened on 2026-09-08, and the only reason anyone noticed is
|
||||||
|
// that the fallback above did not exist yet and the app aborted
|
||||||
|
// instead. A silent fallback needs this line to stay honest.
|
||||||
|
{
|
||||||
|
let info = adapter.get_info();
|
||||||
|
log::info!(
|
||||||
|
"iris renderer: {name} ({backend:?}, {driver}{driver_info}) on {backends:?}",
|
||||||
|
name = info.name,
|
||||||
|
backend = info.backend,
|
||||||
|
driver = info.driver,
|
||||||
|
driver_info = if info.driver_info.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" {}", info.driver_info)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No features beyond what wgpu asks for by default, and no
|
||||||
|
// binding-array limits: the atlas is one texture_2d_array and a
|
||||||
|
// standalone image is its own ordinary bind group, neither of which
|
||||||
|
// needs descriptor indexing. See TEXTURES.md's "Recommended shape"
|
||||||
|
// for why the old binding array asked for
|
||||||
|
// VK_EXT_descriptor_indexing unconditionally and did not survive a
|
||||||
|
// real share of Android GPUs. `iris_core::device_limits()` is
|
||||||
|
// shared with the Android backend; see its own doc for why it is
|
||||||
|
// not simply `Limits::default()`.
|
||||||
let (device, queue) = adapter
|
let (device, queue) = adapter
|
||||||
.request_device(&DeviceDescriptor {
|
.request_device(&DeviceDescriptor {
|
||||||
required_features: Features::TEXTURE_BINDING_ARRAY
|
required_limits: iris_core::device_limits(),
|
||||||
| Features::PARTIALLY_BOUND_BINDING_ARRAY
|
|
||||||
| Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING,
|
|
||||||
required_limits: Limits {
|
|
||||||
max_binding_array_elements_per_shader_stage: ui_limits
|
|
||||||
.max_binding_array_elements_per_shader_stage(),
|
|
||||||
max_binding_array_sampler_elements_per_shader_stage: ui_limits
|
|
||||||
.max_binding_array_sampler_elements_per_shader_stage(),
|
|
||||||
max_buffer_size: 1 << 30,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.block_on()
|
.block_on()
|
||||||
@@ -113,9 +197,16 @@ impl UiRenderer {
|
|||||||
let config = SurfaceConfiguration {
|
let config = SurfaceConfiguration {
|
||||||
usage: TextureUsages::RENDER_ATTACHMENT,
|
usage: TextureUsages::RENDER_ATTACHMENT,
|
||||||
format: surface_format,
|
format: surface_format,
|
||||||
|
// wgpu 30's new field; `Auto` is what every earlier version did.
|
||||||
|
color_space: SurfaceColorSpace::Auto,
|
||||||
width: size.width,
|
width: size.width,
|
||||||
height: size.height,
|
height: size.height,
|
||||||
present_mode: PresentMode::AutoNoVsync,
|
// Vsync, because a toolkit aiming at battery life must not present
|
||||||
|
// frames a display will never show: AutoNoVsync accepts them as
|
||||||
|
// fast as the GPU will take them, so a redraw burst costs whatever
|
||||||
|
// the hardware can be made to do rather than one frame.
|
||||||
|
// AutoVsync picks Fifo, which every backend supports.
|
||||||
|
present_mode: PresentMode::AutoVsync,
|
||||||
alpha_mode: surface_caps.alpha_modes[0],
|
alpha_mode: surface_caps.alpha_modes[0],
|
||||||
desired_maximum_frame_latency: 2,
|
desired_maximum_frame_latency: 2,
|
||||||
view_formats: vec![],
|
view_formats: vec![],
|
||||||
@@ -125,7 +216,19 @@ impl UiRenderer {
|
|||||||
|
|
||||||
let encoder = Self::create_encoder(&device);
|
let encoder = Self::create_encoder(&device);
|
||||||
|
|
||||||
let ui = UiRenderNode::new(&device, &queue, &config, ui_limits);
|
// Unlike the Android backend, the desktop backend has no on-screen
|
||||||
|
// fallback to show a diagnostic through, so a renderer-creation
|
||||||
|
// failure still panics here -- but now with wgpu's full "Caused
|
||||||
|
// by:" chain as the message, since `UiRenderNode::new` returns it
|
||||||
|
// rather than letting wgpu's own default handler panic first (see
|
||||||
|
// that function's doc comment).
|
||||||
|
// Physical size, the same units the swapchain, `WindowEvent::
|
||||||
|
// Resized`, the pointer and the widget tree all use -- see
|
||||||
|
// `default::content_scale` for why this backend stopped dividing
|
||||||
|
// into a separate logical space, and what disagreed while it did.
|
||||||
|
let physical_size = Vec2::new(size.width as f32, size.height as f32);
|
||||||
|
let ui = UiRenderNode::new(&device, &queue, &config, physical_size)
|
||||||
|
.expect("Could not create iris render node!");
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
surface,
|
surface,
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
use crate::prelude::*;
|
|
||||||
use std::{
|
|
||||||
ops::{BitOr, Deref, DerefMut},
|
|
||||||
rc::Rc,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
|
||||||
pub enum CursorButton {
|
|
||||||
Left,
|
|
||||||
Right,
|
|
||||||
Middle,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
|
||||||
pub enum CursorSense {
|
|
||||||
PressStart(CursorButton),
|
|
||||||
Pressing(CursorButton),
|
|
||||||
PressEnd(CursorButton),
|
|
||||||
HoverStart,
|
|
||||||
Hovering,
|
|
||||||
HoverEnd,
|
|
||||||
Scroll,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct CursorSenses(Vec<CursorSense>);
|
|
||||||
|
|
||||||
impl Event for CursorSenses {
|
|
||||||
type Data<'a> = CursorData<'a>;
|
|
||||||
type State = SensorState;
|
|
||||||
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
|
|
||||||
if let Some(sense) = should_run(self, &data.cursor, data.hover) {
|
|
||||||
let mut data = data.clone();
|
|
||||||
data.sense = sense;
|
|
||||||
Some(data)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CursorSense {
|
|
||||||
pub fn click() -> Self {
|
|
||||||
Self::PressStart(CursorButton::Left)
|
|
||||||
}
|
|
||||||
pub fn click_or_drag() -> CursorSenses {
|
|
||||||
Self::click() | Self::Pressing(CursorButton::Left)
|
|
||||||
}
|
|
||||||
pub fn unclick() -> Self {
|
|
||||||
Self::PressEnd(CursorButton::Left)
|
|
||||||
}
|
|
||||||
pub fn is_dragging(&self) -> bool {
|
|
||||||
matches!(self, CursorSense::Pressing(CursorButton::Left))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Clone)]
|
|
||||||
pub struct CursorState {
|
|
||||||
pub pos: Vec2,
|
|
||||||
pub exists: bool,
|
|
||||||
pub buttons: CursorButtons,
|
|
||||||
pub scroll_delta: Vec2,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Clone)]
|
|
||||||
pub struct CursorButtons {
|
|
||||||
pub left: ActivationState,
|
|
||||||
pub middle: ActivationState,
|
|
||||||
pub right: ActivationState,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CursorButtons {
|
|
||||||
pub fn select(&self, button: &CursorButton) -> &ActivationState {
|
|
||||||
match button {
|
|
||||||
CursorButton::Left => &self.left,
|
|
||||||
CursorButton::Right => &self.right,
|
|
||||||
CursorButton::Middle => &self.middle,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn end_frame(&mut self) {
|
|
||||||
self.left.end_frame();
|
|
||||||
self.middle.end_frame();
|
|
||||||
self.right.end_frame();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn iter(&self) -> impl Iterator<Item = (CursorButton, &ActivationState)> {
|
|
||||||
[
|
|
||||||
CursorButton::Left,
|
|
||||||
CursorButton::Middle,
|
|
||||||
CursorButton::Right,
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.map(|b| (b, self.select(&b)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CursorState {
|
|
||||||
pub fn end_frame(&mut self) {
|
|
||||||
self.buttons.end_frame();
|
|
||||||
self.scroll_delta = Vec2::ZERO;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
|
||||||
pub enum ActivationState {
|
|
||||||
Start,
|
|
||||||
On,
|
|
||||||
End,
|
|
||||||
#[default]
|
|
||||||
Off,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// this and other similar stuff has a generic
|
|
||||||
/// because I kind of want to make CursorModule generic
|
|
||||||
/// or basically have some way to have custom senses
|
|
||||||
/// that depend on active widget positions
|
|
||||||
/// but I'm not sure how or if worth it
|
|
||||||
pub struct Sensor<Ctx: HasEvents, Data> {
|
|
||||||
pub senses: CursorSenses,
|
|
||||||
pub f: Rc<dyn EventFn<Ctx, Data>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type SenseShape = UiRegion;
|
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
|
||||||
pub struct SensorState {
|
|
||||||
pub hover: ActivationState,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct CursorData<'a> {
|
|
||||||
/// where this widget was hit
|
|
||||||
pub pos: Vec2,
|
|
||||||
pub size: Vec2,
|
|
||||||
pub scroll_delta: Vec2,
|
|
||||||
pub hover: ActivationState,
|
|
||||||
pub cursor: CursorState,
|
|
||||||
/// the first sense that triggered this
|
|
||||||
pub sense: CursorSense,
|
|
||||||
pub render: &'a UiRenderState,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait SensorUi {
|
|
||||||
fn run_sensors<Rsc: HasEvents>(
|
|
||||||
&self,
|
|
||||||
rsc: &mut Rsc,
|
|
||||||
state: &mut Rsc::State,
|
|
||||||
cursor: CursorState,
|
|
||||||
window_size: Vec2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SensorUi for UiRenderState {
|
|
||||||
fn run_sensors<Rsc: HasEvents>(
|
|
||||||
&self,
|
|
||||||
rsc: &mut Rsc,
|
|
||||||
state: &mut Rsc::State,
|
|
||||||
cursor: CursorState,
|
|
||||||
window_size: Vec2,
|
|
||||||
) {
|
|
||||||
// in order to remove this take, need to store active list in UiRenderState somehow
|
|
||||||
// this would probably be done through a generic parameter that adds yet another rsc /
|
|
||||||
// state like thing, but local to render state, and is passed to UiRsc events so you can
|
|
||||||
// update it there?
|
|
||||||
let mut active = std::mem::take(&mut rsc.events_mut().get_type::<CursorSense>().active);
|
|
||||||
for layer in self.layers.indices().rev() {
|
|
||||||
let mut sensed = false;
|
|
||||||
for (id, sensor) in active.get_mut(&layer).into_flat_iter() {
|
|
||||||
let shape = self.active.get(id).unwrap().region;
|
|
||||||
let region = shape.to_px(window_size);
|
|
||||||
let in_shape = cursor.exists && region.contains(cursor.pos);
|
|
||||||
sensor.hover.update(in_shape);
|
|
||||||
if sensor.hover == ActivationState::Off {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
sensed = true;
|
|
||||||
|
|
||||||
let cursor = cursor.clone();
|
|
||||||
|
|
||||||
let data = CursorData {
|
|
||||||
pos: cursor.pos - region.top_left,
|
|
||||||
size: region.bot_right - region.top_left,
|
|
||||||
scroll_delta: cursor.scroll_delta,
|
|
||||||
hover: sensor.hover,
|
|
||||||
cursor,
|
|
||||||
// this does not have any meaning;
|
|
||||||
// might wanna set up Event to have a prepare stage
|
|
||||||
sense: CursorSense::Hovering,
|
|
||||||
render: self,
|
|
||||||
};
|
|
||||||
rsc.run_event::<CursorSense>(*id, data, state);
|
|
||||||
}
|
|
||||||
if sensed {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rsc.events_mut().get_type::<CursorSense>().active = active;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn should_run(
|
|
||||||
senses: &CursorSenses,
|
|
||||||
cursor: &CursorState,
|
|
||||||
hover: ActivationState,
|
|
||||||
) -> Option<CursorSense> {
|
|
||||||
for sense in senses.iter() {
|
|
||||||
if match sense {
|
|
||||||
CursorSense::PressStart(button) => cursor.buttons.select(button).is_start(),
|
|
||||||
CursorSense::Pressing(button) => cursor.buttons.select(button).is_on(),
|
|
||||||
CursorSense::PressEnd(button) => cursor.buttons.select(button).is_end(),
|
|
||||||
CursorSense::HoverStart => hover.is_start(),
|
|
||||||
CursorSense::Hovering => hover.is_on(),
|
|
||||||
CursorSense::HoverEnd => hover.is_end(),
|
|
||||||
CursorSense::Scroll => cursor.scroll_delta != Vec2::ZERO,
|
|
||||||
} {
|
|
||||||
return Some(*sense);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActivationState {
|
|
||||||
pub fn is_start(&self) -> bool {
|
|
||||||
*self == Self::Start
|
|
||||||
}
|
|
||||||
pub fn is_on(&self) -> bool {
|
|
||||||
*self == Self::Start || *self == Self::On
|
|
||||||
}
|
|
||||||
pub fn is_end(&self) -> bool {
|
|
||||||
*self == Self::End
|
|
||||||
}
|
|
||||||
pub fn is_off(&self) -> bool {
|
|
||||||
*self == Self::End || *self == Self::Off
|
|
||||||
}
|
|
||||||
pub fn update(&mut self, on: bool) {
|
|
||||||
*self = match *self {
|
|
||||||
Self::Start => match on {
|
|
||||||
true => Self::On,
|
|
||||||
false => Self::End,
|
|
||||||
},
|
|
||||||
Self::On => match on {
|
|
||||||
true => Self::On,
|
|
||||||
false => Self::End,
|
|
||||||
},
|
|
||||||
Self::End => match on {
|
|
||||||
true => Self::Start,
|
|
||||||
false => Self::Off,
|
|
||||||
},
|
|
||||||
Self::Off => match on {
|
|
||||||
true => Self::Start,
|
|
||||||
false => Self::Off,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn end_frame(&mut self) {
|
|
||||||
match self {
|
|
||||||
Self::Start => *self = Self::On,
|
|
||||||
Self::End => *self = Self::Off,
|
|
||||||
_ => (),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventLike for CursorSense {
|
|
||||||
type Event = CursorSenses;
|
|
||||||
fn into_event(self) -> Self::Event {
|
|
||||||
self.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Deref for CursorSenses {
|
|
||||||
type Target = Vec<CursorSense>;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DerefMut for CursorSenses {
|
|
||||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
||||||
&mut self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<CursorSense> for CursorSenses {
|
|
||||||
fn from(val: CursorSense) -> Self {
|
|
||||||
CursorSenses(vec![val])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BitOr for CursorSense {
|
|
||||||
type Output = CursorSenses;
|
|
||||||
|
|
||||||
fn bitor(self, rhs: Self) -> Self::Output {
|
|
||||||
CursorSenses(vec![self, rhs])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BitOr<CursorSense> for CursorSenses {
|
|
||||||
type Output = Self;
|
|
||||||
|
|
||||||
fn bitor(mut self, rhs: CursorSense) -> Self::Output {
|
|
||||||
self.0.push(rhs);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
//! The trace toggle for the `iris::input`/`iris::frame` diagnostics (Iris's
|
||||||
|
//! 2026-09-07 request: "add another button to copy input event info ...
|
||||||
|
//! instrument a lot of the code with timings"), and the one place both
|
||||||
|
//! call sites' `iris::frame` line is written from.
|
||||||
|
//!
|
||||||
|
//! **Why a crate-level flag instead of `log::log_enabled!`/
|
||||||
|
//! `log::set_max_level`**: the app already installs its logger at
|
||||||
|
//! `LevelFilter::Debug` (`iris/android-app/src/lib.rs`'s `JNI_OnLoad`), so
|
||||||
|
//! a `log::Level::Debug` line reaches `client_core::log_ring`'s ring
|
||||||
|
//! regardless of what this instrument would prefer -- `RingLogger::enabled`
|
||||||
|
//! is unconditionally `true` by design (its own doc: "the ring wants
|
||||||
|
//! everything"). So the level alone cannot give these two targets a
|
||||||
|
//! default-off switch; the gate has to live on this side, checked before
|
||||||
|
//! `log::debug!` is even reached.
|
||||||
|
//!
|
||||||
|
//! **Why default off matters**: the ring is 2000 lines / 256 KiB
|
||||||
|
//! (`client_core::log_ring::DEFAULT_MAX_LINES`/`DEFAULT_MAX_BYTES`), and a
|
||||||
|
//! 120Hz session logging both a line per touch sample and a line per frame
|
||||||
|
//! fills that in seconds -- so a caller turns this on only for the length
|
||||||
|
//! of whatever is being investigated, and the report says so at its top
|
||||||
|
//! (a caller's job; see `iris::diagnostics::trace_enabled` used at the top
|
||||||
|
//! of whatever builds the report).
|
||||||
|
//!
|
||||||
|
//! **Not yet wired to a control**: the Diagnostics pane that would hold the
|
||||||
|
//! switch is in `iris/android-app/src/bench_client.rs`, which another agent
|
||||||
|
//! has open at the same time this was written. `set_trace` is the whole
|
||||||
|
//! surface a button needs; wiring one is a follow-up.
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use iris_core::UiRenderState;
|
||||||
|
|
||||||
|
static TRACE: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Turns the `iris::input`/`iris::frame` `debug!` lines on or off. Off by
|
||||||
|
/// default -- see the module doc for why turning the level on alone would
|
||||||
|
/// not do it.
|
||||||
|
pub fn set_trace(on: bool) {
|
||||||
|
TRACE.store(on, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the `iris::input`/`iris::frame` lines are enabled right now --
|
||||||
|
/// what a report's header reads before deciding what to say about the
|
||||||
|
/// lines it does or doesn't hold (UI_RULES.md: "design the unknown state
|
||||||
|
/// first").
|
||||||
|
pub fn trace_enabled() -> bool {
|
||||||
|
TRACE.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `iris::frame` line, called once per frame from each backend's own
|
||||||
|
/// frame function -- `android::view::IrisViewPeer::render`,
|
||||||
|
/// `default::DefaultApp::window_event`'s `RedrawRequested` arm, and
|
||||||
|
/// `harness::Harness::frame` -- after the draw (or, on the harness, where a
|
||||||
|
/// draw would be; `draw` is `Duration::ZERO` there since nothing is
|
||||||
|
/// actually submitted to a GPU).
|
||||||
|
///
|
||||||
|
/// `render.update(...)` must already have run this frame: this reads back
|
||||||
|
/// what it recorded (`UiRenderState::last_layout_duration`/
|
||||||
|
/// `last_redraw_kind`/`frame_number`) rather than timing anything itself,
|
||||||
|
/// so a caller's own measurement of the phase around `update()` and around
|
||||||
|
/// its own draw call are the only two `Instant` pairs in the whole path --
|
||||||
|
/// see each call site's own comment for why it is not restructured to fit
|
||||||
|
/// this instead.
|
||||||
|
pub fn log_frame(render: &UiRenderState, now: Instant, draw: Duration, animating: bool) {
|
||||||
|
if !trace_enabled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let since_input = render
|
||||||
|
.time_since_input(now)
|
||||||
|
.map(|d| format!("{}ms", d.as_millis()))
|
||||||
|
.unwrap_or_else(|| "none".to_string());
|
||||||
|
log::debug!(
|
||||||
|
target: "iris::frame",
|
||||||
|
"iris frame: n={} now={}ms since_input={since_input} layout={:?} draw={:?} \
|
||||||
|
redraw={:?} primitives={} animating={animating}",
|
||||||
|
render.frame_number(),
|
||||||
|
now.duration_since(render.epoch()).as_millis(),
|
||||||
|
render.last_layout_duration(),
|
||||||
|
draw,
|
||||||
|
render.last_redraw_kind(),
|
||||||
|
render.active_primitive_count(),
|
||||||
|
);
|
||||||
|
}
|
||||||
+24
-4
@@ -2,7 +2,21 @@ use iris_core::*;
|
|||||||
use iris_macro::*;
|
use iris_macro::*;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::default::{TaskCtx, TaskUpdate, Tasks};
|
use crate::task::{TaskCtx, TaskUpdate, Tasks};
|
||||||
|
|
||||||
|
/// A field's Enter key (without a shift, in a multi-line field). Backend
|
||||||
|
/// input handling raises it directly rather than through `on`, since a
|
||||||
|
/// field does not know ahead of time whether anything is listening.
|
||||||
|
#[derive(Eq, PartialEq, Hash, Clone)]
|
||||||
|
pub struct Submit;
|
||||||
|
impl Event for Submit {}
|
||||||
|
|
||||||
|
/// A field's content changed as a result of input the backend applied
|
||||||
|
/// directly to it (a keystroke, an IME commit) rather than through a
|
||||||
|
/// widget event handler.
|
||||||
|
#[derive(Eq, PartialEq, Hash, Clone)]
|
||||||
|
pub struct Edited;
|
||||||
|
impl Event for Edited {}
|
||||||
|
|
||||||
pub trait Eventable<Rsc: HasEvents, Tag>: WidgetLike<Rsc, Tag> {
|
pub trait Eventable<Rsc: HasEvents, Tag>: WidgetLike<Rsc, Tag> {
|
||||||
fn on<E: EventLike>(
|
fn on<E: EventLike>(
|
||||||
@@ -30,13 +44,19 @@ impl<WL: WidgetLike<Rsc, Tag>, Rsc: HasEvents, Tag> Eventable<Rsc, Tag> for WL {
|
|||||||
|
|
||||||
widget_trait! {
|
widget_trait! {
|
||||||
pub trait TaskEventable<Rsc: HasEvents + HasTasks>;
|
pub trait TaskEventable<Rsc: HasEvents + HasTasks>;
|
||||||
fn task_on<'a, E: EventLike, F: AsyncWidgetEventFn<Rsc, WL::Widget>>(
|
/// No `Data: Send` bound, deliberately: the registered handler below
|
||||||
|
/// takes `|_, rsc|` and the event's data never crosses into the
|
||||||
|
/// spawned future -- `AsyncEventIdCtx` carries the widget id and the
|
||||||
|
/// task handle and nothing else. The bound used to be here anyway, and
|
||||||
|
/// it was the whole reason `CursorData`'s pointer state was behind a
|
||||||
|
/// `Mutex` rather than owned by the input handler (Iris, 2026-09-08:
|
||||||
|
/// never reach for a lock first).
|
||||||
|
fn task_on<E: EventLike, F: AsyncWidgetEventFn<Rsc, WL::Widget>>(
|
||||||
self,
|
self,
|
||||||
event: E,
|
event: E,
|
||||||
f: F,
|
f: F,
|
||||||
) -> impl WidgetIdFn<Rsc, WL::Widget>
|
) -> impl WidgetIdFn<Rsc, WL::Widget>
|
||||||
where <E::Event as Event>::Data<'a>: Send,
|
where for<'b> F::CallRefFuture<'b>: Send,
|
||||||
for<'b> F::CallRefFuture<'b>: Send,
|
|
||||||
{
|
{
|
||||||
let f = Arc::new(f);
|
let f = Arc::new(f);
|
||||||
move |rsc| {
|
move |rsc| {
|
||||||
|
|||||||
+428
@@ -0,0 +1,428 @@
|
|||||||
|
//! Layer 1 of docs/RUST.md's "Three test layers": a whole screen driven
|
||||||
|
//! in-process with **no window, no compositor and no GPU**, on an
|
||||||
|
//! explicit clock and a replayed touch stream.
|
||||||
|
//!
|
||||||
|
//! `layout_tests.rs` and `sense_tests.rs` already build trees over
|
||||||
|
//! `UiRenderState` with a hand-rolled `Rsc` each; this is the same idea
|
||||||
|
//! carried far enough to open a real app screen (`transcript-ui`'s, over
|
||||||
|
//! the bench fixture -- see the `transcript-fixture` crate) at the
|
||||||
|
//! phone's size and density, feed it a recorded flick, and assert on
|
||||||
|
//! where the list ended up. What it answers that the emulator cannot:
|
||||||
|
//! Android batches a 120Hz flick into one or two `MotionEvent`s
|
||||||
|
//! (`CursorState::time`), and a `ui-trace` swipe is many evenly-spaced
|
||||||
|
//! ones -- so the gesture shape a finger actually makes is only
|
||||||
|
//! reproducible from a *file* of timestamped samples.
|
||||||
|
//!
|
||||||
|
//! It is a third backend in the sense `default/` and `android/` are, and
|
||||||
|
//! deliberately the smallest one: the platform half of each of those
|
||||||
|
//! (a surface, an IME, a URL opener) becomes a recorded fact here --
|
||||||
|
//! [`HarnessState::keyboard_shown`], [`HarnessState::opened_urls`] --
|
||||||
|
//! so a test can assert the platform *was asked*, which is the only
|
||||||
|
//! thing either backend does with those calls anyway.
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||||
|
//! let screen = transcript_ui::build(&mut h.rsc, &mut h.state, rows);
|
||||||
|
//! h.frame(0);
|
||||||
|
//! h.replay(&TouchScript::parse(include_str!("flick.touch"))?);
|
||||||
|
//! h.frames_until(20, 2_000, 8);
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
use std::marker::PhantomData;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// One replayed pointer sample: what Android's `MotionEvent` carries, cut
|
||||||
|
/// down to the part iris reads (`IrisViewPeer::on_touch_event`).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum TouchAction {
|
||||||
|
Down,
|
||||||
|
Move,
|
||||||
|
Up,
|
||||||
|
/// The gesture taken away by the system (a parent view claiming it, a
|
||||||
|
/// call arriving, the swipe up from the bottom edge to leave the
|
||||||
|
/// app). It ends the press, because a release that never arrives
|
||||||
|
/// leaves pointer capture held forever -- but it is not a release,
|
||||||
|
/// and nothing follows from it: no tap, no selection, no fling. See
|
||||||
|
/// `CursorState::cancelled`, which is what it sets.
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TouchAction {
|
||||||
|
fn parse(word: &str) -> Option<Self> {
|
||||||
|
match word {
|
||||||
|
"down" => Some(Self::Down),
|
||||||
|
"move" => Some(Self::Move),
|
||||||
|
"up" => Some(Self::Up),
|
||||||
|
"cancel" => Some(Self::Cancel),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inverse of [`Self::parse`] -- what [`Harness::touch`] hands
|
||||||
|
/// [`crate::sense::log_input_event`], so an `iris::input` line and a
|
||||||
|
/// `.touch` file agree on one spelling of each action.
|
||||||
|
pub fn word(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Down => "down",
|
||||||
|
Self::Move => "move",
|
||||||
|
Self::Up => "up",
|
||||||
|
Self::Cancel => "cancel",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct TouchSample {
|
||||||
|
/// Milliseconds since the start of the recording -- the sample's own
|
||||||
|
/// time, which becomes `CursorState::time`. See that field's doc for
|
||||||
|
/// why a replay may not date its samples by when the loop got to
|
||||||
|
/// them.
|
||||||
|
pub t_ms: u64,
|
||||||
|
pub action: TouchAction,
|
||||||
|
pub pos: Vec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A recorded gesture: one `t_ms action x y` line per sample, `#` and
|
||||||
|
/// blank lines ignored. Deliberately a plain text file rather than a
|
||||||
|
/// serialisation format -- it is written by hand as often as it is
|
||||||
|
/// recorded, and a diff of one has to be readable.
|
||||||
|
pub struct TouchScript {
|
||||||
|
pub samples: Vec<TouchSample>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TouchScript {
|
||||||
|
/// Parses a script, naming the line and what was wrong with it: these
|
||||||
|
/// are hand-written files, so a typo is the ordinary case and
|
||||||
|
/// "expected 4 fields" without a line number is not enough to fix it.
|
||||||
|
pub fn parse(text: &str) -> Result<Self, String> {
|
||||||
|
let mut samples: Vec<TouchSample> = Vec::new();
|
||||||
|
for (i, line) in text.lines().enumerate() {
|
||||||
|
let line = line.split('#').next().unwrap_or("").trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let at = |what: &str| format!("touch script line {}: {what}: {line:?}", i + 1);
|
||||||
|
let mut words = line.split_whitespace();
|
||||||
|
let (Some(t), Some(action), Some(x), Some(y), None) = (
|
||||||
|
words.next(),
|
||||||
|
words.next(),
|
||||||
|
words.next(),
|
||||||
|
words.next(),
|
||||||
|
words.next(),
|
||||||
|
) else {
|
||||||
|
return Err(at("expected `t_ms action x y`"));
|
||||||
|
};
|
||||||
|
let t_ms: u64 = t.parse().map_err(|_| at("t_ms is not a whole number"))?;
|
||||||
|
let action = TouchAction::parse(action)
|
||||||
|
.ok_or_else(|| at("action is not down/move/up/cancel"))?;
|
||||||
|
let x: f32 = x.parse().map_err(|_| at("x is not a number"))?;
|
||||||
|
let y: f32 = y.parse().map_err(|_| at("y is not a number"))?;
|
||||||
|
if let Some(last) = samples.last()
|
||||||
|
&& t_ms < last.t_ms
|
||||||
|
{
|
||||||
|
return Err(at("samples must be in time order"));
|
||||||
|
}
|
||||||
|
samples.push(TouchSample {
|
||||||
|
t_ms,
|
||||||
|
action,
|
||||||
|
pos: Vec2::new(x, y),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self { samples })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last sample's time, i.e. how long the recording runs.
|
||||||
|
pub fn end_ms(&self) -> u64 {
|
||||||
|
self.samples.last().map(|s| s.t_ms).unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts the frames something asked for without drawing any -- the
|
||||||
|
/// harness's `RequestRedraw`. A `LazySpan` coasting through a fling asks for
|
||||||
|
/// the next frame through this (`UiData::animate` and `Widget::tick`), so a test can
|
||||||
|
/// tell "nothing moved" from "nothing was even asked to move".
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct RedrawCounter(AtomicUsize);
|
||||||
|
|
||||||
|
impl RedrawCounter {
|
||||||
|
pub fn count(&self) -> usize {
|
||||||
|
self.0.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestRedraw for RedrawCounter {
|
||||||
|
fn request_redraw(&self) {
|
||||||
|
self.0.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The harness's app state: what each real backend keeps for the platform
|
||||||
|
/// half, recorded instead of performed.
|
||||||
|
pub struct HarnessState {
|
||||||
|
pub root: Option<StrongWidget>,
|
||||||
|
pub focus: Option<WeakWidget<TextEdit>>,
|
||||||
|
last_click: Instant,
|
||||||
|
/// How many times a tap asked for the keyboard (`FocusHost::
|
||||||
|
/// focus_gained` with a region -- `showSoftInput` on Android,
|
||||||
|
/// `set_ime_cursor_area` on winit). The platform's own answer is not
|
||||||
|
/// available here, so this says what was *asked*, and a test must not
|
||||||
|
/// read it as "the IME is up".
|
||||||
|
pub keyboard_shown: usize,
|
||||||
|
/// Every URL a tapped link asked the platform to open, in order.
|
||||||
|
pub opened_urls: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HarnessState {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
root: None,
|
||||||
|
focus: None,
|
||||||
|
last_click: Instant::now(),
|
||||||
|
keyboard_shown: 0,
|
||||||
|
opened_urls: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasRoot for HarnessState {
|
||||||
|
fn set_root(&mut self, root: StrongWidget) {
|
||||||
|
self.root = Some(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FocusHost for HarnessState {
|
||||||
|
fn recent_click(&mut self) -> bool {
|
||||||
|
crate::attr::recent_click(&mut self.last_click)
|
||||||
|
}
|
||||||
|
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
|
||||||
|
self.focus = id;
|
||||||
|
}
|
||||||
|
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||||
|
self.focus == Some(id)
|
||||||
|
}
|
||||||
|
fn focus_gained(&mut self, region: Option<PixelRegion>) {
|
||||||
|
if region.is_some() {
|
||||||
|
self.keyboard_shown += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenUrl for HarnessState {
|
||||||
|
fn open_url(&mut self, url: &str) {
|
||||||
|
self.opened_urls.push(url.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The harness's `Rsc` -- identical in substance to `DefaultRsc`/
|
||||||
|
/// `AndroidRsc` minus the windowing, for the same reason those two are
|
||||||
|
/// separate types (`AndroidRsc`'s own doc).
|
||||||
|
pub struct HarnessRsc {
|
||||||
|
pub ui: UiData,
|
||||||
|
pub events: EventManager<Self>,
|
||||||
|
pub tasks: Tasks<Self>,
|
||||||
|
pub state: WidgetState,
|
||||||
|
_state: PhantomData<HarnessState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiRsc for HarnessRsc {
|
||||||
|
fn ui(&self) -> &UiData {
|
||||||
|
&self.ui
|
||||||
|
}
|
||||||
|
fn ui_mut(&mut self) -> &mut UiData {
|
||||||
|
&mut self.ui
|
||||||
|
}
|
||||||
|
fn on_draw(&mut self, active: &ActiveData) {
|
||||||
|
self.events.draw(active);
|
||||||
|
}
|
||||||
|
fn on_undraw(&mut self, active: &ActiveData) {
|
||||||
|
self.events.undraw(active);
|
||||||
|
}
|
||||||
|
fn on_remove(&mut self, id: WidgetId) {
|
||||||
|
self.events.remove(id);
|
||||||
|
self.state.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasState for HarnessRsc {
|
||||||
|
type State = HarnessState;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasEvents for HarnessRsc {
|
||||||
|
fn events(&self) -> &EventManager<Self> {
|
||||||
|
&self.events
|
||||||
|
}
|
||||||
|
fn events_mut(&mut self) -> &mut EventManager<Self> {
|
||||||
|
&mut self.events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasTasks for HarnessRsc {
|
||||||
|
fn tasks_mut(&mut self) -> &mut Tasks<Self> {
|
||||||
|
&mut self.tasks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasWidgetState for HarnessRsc {
|
||||||
|
fn widget_state(&self) -> &WidgetState {
|
||||||
|
&self.state
|
||||||
|
}
|
||||||
|
fn widget_state_mut(&mut self) -> &mut WidgetState {
|
||||||
|
&mut self.state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<I: RscIdx<HarnessRsc>> std::ops::Index<I> for HarnessRsc {
|
||||||
|
type Output = I::Output;
|
||||||
|
fn index(&self, index: I) -> &Self::Output {
|
||||||
|
index.get(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<I: RscIdx<HarnessRsc>> std::ops::IndexMut<I> for HarnessRsc {
|
||||||
|
fn index_mut(&mut self, index: I) -> &mut Self::Output {
|
||||||
|
index.get_mut(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A screen running with no window: the widget tree, the frame loop and
|
||||||
|
/// the pointer, all advanced by the caller. See the module doc.
|
||||||
|
pub struct Harness {
|
||||||
|
pub rsc: HarnessRsc,
|
||||||
|
pub render: UiRenderState,
|
||||||
|
pub state: HarnessState,
|
||||||
|
task_recv: TaskMsgReceiver<HarnessRsc>,
|
||||||
|
redraws: Arc<RedrawCounter>,
|
||||||
|
cursor: CursorState,
|
||||||
|
/// Time zero. Every `t_ms` in this harness is an offset from here, so
|
||||||
|
/// nothing reads the wall clock -- see [`Self::at`].
|
||||||
|
base: Instant,
|
||||||
|
size: Vec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Harness {
|
||||||
|
/// `size` is in physical pixels and `density` is physical pixels per
|
||||||
|
/// dp, the pair Android reads from the surface and
|
||||||
|
/// `DisplayMetrics.density` (`AndroidUiState::content_scale`). The
|
||||||
|
/// phone's own numbers are `transcript_fixture::PHONE_SIZE`/
|
||||||
|
/// `PHONE_SCALE`.
|
||||||
|
pub fn new(size: Vec2, density: f32) -> Self {
|
||||||
|
let redraws = Arc::new(RedrawCounter::default());
|
||||||
|
let (tasks, task_recv) = Tasks::init(redraws.clone());
|
||||||
|
let mut rsc = HarnessRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
tasks,
|
||||||
|
state: WidgetState::default(),
|
||||||
|
_state: PhantomData,
|
||||||
|
};
|
||||||
|
rsc.ui.text.density = density;
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.set_density(density);
|
||||||
|
render.resize(size);
|
||||||
|
Self {
|
||||||
|
rsc,
|
||||||
|
render,
|
||||||
|
state: HarnessState::new(),
|
||||||
|
task_recv,
|
||||||
|
redraws,
|
||||||
|
cursor: CursorState::default(),
|
||||||
|
base: Instant::now(),
|
||||||
|
size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `Instant` this harness means by `t_ms`. Public because a
|
||||||
|
/// caller driving `ScrollController::tick` or `DragGesture` by hand needs
|
||||||
|
/// to date those calls on the same clock the touch samples use.
|
||||||
|
pub fn at(&self, t_ms: u64) -> Instant {
|
||||||
|
self.base + Duration::from_millis(t_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn size(&self) -> Vec2 {
|
||||||
|
self.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many frames were asked for so far -- see [`RedrawCounter`].
|
||||||
|
pub fn redraws(&self) -> usize {
|
||||||
|
self.redraws.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One frame at `t_ms`: drain finished tasks, advance anything
|
||||||
|
/// animating, lay out and "draw". The same three steps
|
||||||
|
/// `DefaultApp::window_event`'s `RedrawRequested` arm and
|
||||||
|
/// `IrisViewPeer::render` take, minus handing primitives to a GPU.
|
||||||
|
pub fn frame(&mut self, t_ms: u64) {
|
||||||
|
while let Ok(update) = self.task_recv.try_recv() {
|
||||||
|
update(&mut self.state, &mut self.rsc);
|
||||||
|
}
|
||||||
|
let now = self.at(t_ms);
|
||||||
|
let animating = self.rsc.ui.tick_animations(now);
|
||||||
|
self.render.update(&self.state.root, &mut self.rsc);
|
||||||
|
// No GPU here, so there is no draw phase to time -- `draw` is
|
||||||
|
// always zero. `layout`/`redraw`/`primitives` are still real,
|
||||||
|
// because `render.update` just ran; see
|
||||||
|
// `iris::diagnostics::log_frame`'s own doc for why this reads
|
||||||
|
// those back rather than timing anything itself.
|
||||||
|
crate::diagnostics::log_frame(&self.render, now, Duration::ZERO, animating);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frames every `step_ms` up to and including `end_ms` -- what a
|
||||||
|
/// fling needs, since it moves only while something ticks it
|
||||||
|
/// (`ScrollController::fling`'s doc). Returns the time of the last frame run.
|
||||||
|
pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 {
|
||||||
|
debug_assert!(step_ms > 0, "a frame loop with no step never ends");
|
||||||
|
let mut t = from_ms;
|
||||||
|
while t <= end_ms {
|
||||||
|
self.frame(t);
|
||||||
|
t += step_ms;
|
||||||
|
}
|
||||||
|
t - step_ms
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pointer sample through the sensors, then the frame it belongs
|
||||||
|
/// to -- `IrisViewPeer::on_touch_event` and `after_input`, in one
|
||||||
|
/// call. Each sample is its own input frame, dated by the sample
|
||||||
|
/// rather than by when this ran.
|
||||||
|
pub fn touch(&mut self, action: TouchAction, pos: Vec2, t_ms: u64) {
|
||||||
|
self.cursor.time = self.at(t_ms);
|
||||||
|
self.cursor.pos = pos;
|
||||||
|
match action {
|
||||||
|
TouchAction::Down => {
|
||||||
|
self.cursor.exists = true;
|
||||||
|
self.cursor.buttons.left.update(true);
|
||||||
|
}
|
||||||
|
TouchAction::Move => {}
|
||||||
|
TouchAction::Up => self.cursor.buttons.left.update(false),
|
||||||
|
// The platform taking the gesture away, not the finger
|
||||||
|
// lifting -- see `CursorState::cancelled`.
|
||||||
|
TouchAction::Cancel => {
|
||||||
|
self.cursor.buttons.left.update(false);
|
||||||
|
self.cursor.cancelled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Layer 1's half of `iris::input` (`sense::log_input_event`'s own
|
||||||
|
// doc): no batching happens here, so `historical` is always empty
|
||||||
|
// and `t_ms` is the script's own column, which is what makes this
|
||||||
|
// round-trip through `report_to_touch.py` back into an identical
|
||||||
|
// `TouchScript`.
|
||||||
|
crate::sense::log_input_event(action.word(), pos.x, pos.y, t_ms, &[]);
|
||||||
|
let cursor = self.cursor.clone();
|
||||||
|
self.render
|
||||||
|
.run_sensors(&mut self.rsc, &mut self.state, cursor, self.size);
|
||||||
|
self.frame(t_ms);
|
||||||
|
self.cursor.end_frame();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replays a whole recorded gesture. Nothing is inserted between the
|
||||||
|
/// samples: a file with three lines produces three input frames, so
|
||||||
|
/// the batched shape a real flick arrives in is preserved exactly as
|
||||||
|
/// recorded rather than smoothed into evenly-spaced motion.
|
||||||
|
pub fn replay(&mut self, script: &TouchScript) {
|
||||||
|
for sample in &script.samples {
|
||||||
|
self.touch(sample.action, sample.pos, sample.t_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1079
File diff suppressed because it is too large.
Load diff
+37
-2
@@ -1,24 +1,59 @@
|
|||||||
#![feature(unboxed_closures)]
|
#![feature(unboxed_closures)]
|
||||||
#![feature(fn_traits)]
|
#![feature(fn_traits)]
|
||||||
#![feature(gen_blocks)]
|
// Only `default::DefaultAppState::Event`'s default uses this; unused (and
|
||||||
#![feature(associated_type_defaults)]
|
// warned about) on the android target, which has no such default.
|
||||||
|
#![cfg_attr(not(target_os = "android"), feature(associated_type_defaults))]
|
||||||
#![feature(unsize)]
|
#![feature(unsize)]
|
||||||
#![feature(option_into_flat_iter)]
|
#![feature(option_into_flat_iter)]
|
||||||
#![feature(async_fn_traits)]
|
#![feature(async_fn_traits)]
|
||||||
|
|
||||||
|
// Two windowing backends live side by side, chosen by target rather than by
|
||||||
|
// feature flag: winit everywhere but Android, android-view on it. They are
|
||||||
|
// mutually exclusive rather than both-compiled-in because winit's own
|
||||||
|
// Android support pulls in `android-activity`, which needs one of its
|
||||||
|
// `game-activity`/`native-activity` features selected -- exactly what
|
||||||
|
// `iris-core` was kept free of, and android-view is the framework's own
|
||||||
|
// answer to the same surface on that platform. See RUST.md's I2.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub mod android;
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
pub mod default;
|
pub mod default;
|
||||||
|
|
||||||
|
pub mod attr;
|
||||||
|
pub mod diagnostics;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
|
pub mod harness;
|
||||||
|
pub mod platform;
|
||||||
|
pub mod sense;
|
||||||
|
pub mod state;
|
||||||
|
pub mod task;
|
||||||
pub mod widget;
|
pub mod widget;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod access_tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod layout_tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod sense_tests;
|
||||||
|
|
||||||
pub use iris_core as core;
|
pub use iris_core as core;
|
||||||
pub use iris_macro as macros;
|
pub use iris_macro as macros;
|
||||||
|
|
||||||
pub mod prelude {
|
pub mod prelude {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub use android::*;
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
pub use default::*;
|
pub use default::*;
|
||||||
|
|
||||||
|
pub use attr::*;
|
||||||
pub use event::*;
|
pub use event::*;
|
||||||
pub use iris_core::*;
|
pub use iris_core::*;
|
||||||
pub use iris_macro::*;
|
pub use iris_macro::*;
|
||||||
|
pub use platform::*;
|
||||||
|
pub use sense::*;
|
||||||
|
pub use state::*;
|
||||||
|
pub use task::*;
|
||||||
pub use widget::*;
|
pub use widget::*;
|
||||||
|
|
||||||
pub use iris_core::util::Vec2;
|
pub use iris_core::util::Vec2;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//! Capabilities a widget tree needs from whatever is hosting it, that
|
||||||
|
//! neither iris nor the app can perform itself.
|
||||||
|
//!
|
||||||
|
//! Same shape as [`crate::attr::FocusHost`], and for the same reason: the
|
||||||
|
//! interface is declared here, below, and implemented by each backend
|
||||||
|
//! above (`default/platform.rs`, `android/platform.rs`), so a widget can
|
||||||
|
//! ask for the capability by trait bound instead of a caller threading a
|
||||||
|
//! callback down through every builder.
|
||||||
|
|
||||||
|
/// Hand a URL to whatever the platform opens URLs with.
|
||||||
|
///
|
||||||
|
/// One method rather than a general "run an intent"/"exec" surface: the
|
||||||
|
/// only thing a transcript needs is to follow a link a reader tapped, and
|
||||||
|
/// a narrower capability is a narrower thing to get wrong.
|
||||||
|
///
|
||||||
|
/// **Nothing is reported back.** There is no answer worth branching on --
|
||||||
|
/// the platform either shows a browser or does not, and both are outside
|
||||||
|
/// this process -- so failures are logged where they happen (each impl)
|
||||||
|
/// rather than turned into a `Result` every call site would discard.
|
||||||
|
pub trait OpenUrl {
|
||||||
|
fn open_url(&mut self, url: &str);
|
||||||
|
}
|
||||||
+3171
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,781 @@
|
|||||||
|
//! IRIS_TODO.md's "Input does not fall through by input type": a widget
|
||||||
|
//! that only registered `click()` used to also block a `ScrollArea` meant for
|
||||||
|
//! whatever is behind it, because `run_sensors` decided "consumed, stop
|
||||||
|
//! looking at lower layers" from mere hover, not from anything actually
|
||||||
|
//! matching. Exercised as a plain unit test for the same reason
|
||||||
|
//! `layout_tests.rs` is one: `UiRenderState` and a minimal `HasEvents`
|
||||||
|
//! impl need no GPU or window.
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
use std::{cell::Cell, rc::Rc, time::Instant};
|
||||||
|
|
||||||
|
struct SenseRsc {
|
||||||
|
ui: UiData,
|
||||||
|
events: EventManager<SenseRsc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiRsc for SenseRsc {
|
||||||
|
fn ui(&self) -> &UiData {
|
||||||
|
&self.ui
|
||||||
|
}
|
||||||
|
fn ui_mut(&mut self) -> &mut UiData {
|
||||||
|
&mut self.ui
|
||||||
|
}
|
||||||
|
fn on_draw(&mut self, active: &ActiveData) {
|
||||||
|
self.events.draw(active);
|
||||||
|
}
|
||||||
|
fn on_undraw(&mut self, active: &ActiveData) {
|
||||||
|
self.events.undraw(active);
|
||||||
|
}
|
||||||
|
fn on_remove(&mut self, id: WidgetId) {
|
||||||
|
self.events.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasState for SenseRsc {
|
||||||
|
type State = ();
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasEvents for SenseRsc {
|
||||||
|
fn events(&self) -> &EventManager<Self> {
|
||||||
|
&self.events
|
||||||
|
}
|
||||||
|
fn events_mut(&mut self) -> &mut EventManager<Self> {
|
||||||
|
&mut self.events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cursor_at(pos: Vec2) -> CursorState {
|
||||||
|
CursorState {
|
||||||
|
pos,
|
||||||
|
exists: true,
|
||||||
|
buttons: Default::default(),
|
||||||
|
scroll_delta: Vec2::ZERO,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_button_over_a_list_scrolls_the_list_and_still_clicks() {
|
||||||
|
let mut rsc = SenseRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Both cover the whole window -- the button "sitting over" the list,
|
||||||
|
// the case in IRIS_TODO.md's report.
|
||||||
|
let list = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let list_weak = list.weak();
|
||||||
|
let button = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED));
|
||||||
|
let button_weak = button.weak();
|
||||||
|
|
||||||
|
let scrolled = Rc::new(Cell::new(false));
|
||||||
|
let clicked = Rc::new(Cell::new(false));
|
||||||
|
{
|
||||||
|
let scrolled = scrolled.clone();
|
||||||
|
rsc.register_event(list_weak, CursorSense::Scroll, move |_ctx, _rsc| {
|
||||||
|
scrolled.set(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let clicked = clicked.clone();
|
||||||
|
rsc.register_event(button_weak, CursorSense::click(), move |_ctx, _rsc| {
|
||||||
|
clicked.set(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Stack draws its children on separate layers in order, which is
|
||||||
|
// exactly the "one thing drawn over another" shape `run_sensors`
|
||||||
|
// walks top layer first.
|
||||||
|
let root = rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.add_strong(Stack {
|
||||||
|
children: vec![list.any(), button.any()],
|
||||||
|
size: StackSize::default(),
|
||||||
|
})
|
||||||
|
.any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((100.0, 100.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
let mut state = ();
|
||||||
|
let mut scroll_cursor = cursor_at((50.0, 50.0).into());
|
||||||
|
scroll_cursor.scroll_delta = (0.0, 10.0).into();
|
||||||
|
render.run_sensors(&mut rsc, &mut state, scroll_cursor, (100.0, 100.0).into());
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
scrolled.get(),
|
||||||
|
"a scroll over the button must still reach the list underneath it"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!clicked.get(),
|
||||||
|
"a scroll is not a click; the button must not have fired"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut click_cursor = cursor_at((50.0, 50.0).into());
|
||||||
|
click_cursor.buttons.left = ActivationState::Start;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, click_cursor, (100.0, 100.0).into());
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
clicked.get(),
|
||||||
|
"the button on top must still receive an actual click"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bug behind "finger flings do nothing" (RUST.md's P0 phone report,
|
||||||
|
/// defect 2): a fast gesture's `PressEnd` can land at a screen position
|
||||||
|
/// nothing is registered at -- past the edge of whatever widget noticed
|
||||||
|
/// the press, in a gap, or off the loaded content entirely. Before pointer
|
||||||
|
/// capture, `run_sensors`' hit test simply delivered nothing that frame,
|
||||||
|
/// so a widget mid-drag never saw its release and never got a chance to
|
||||||
|
/// start a fling. `PointerRequests::capture`/`DragGesture` fix this
|
||||||
|
/// by giving the drag's widget every frame regardless of where the
|
||||||
|
/// pointer is, including the terminal `Drop` in place of `PressEnd`.
|
||||||
|
#[test]
|
||||||
|
fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() {
|
||||||
|
let mut rsc = SenseRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// A small draggable widget in the corner -- the release below lands
|
||||||
|
// far outside it, exactly the "moved off the hit region" case.
|
||||||
|
let draggable = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
|
||||||
|
let draggable_weak = draggable.weak();
|
||||||
|
|
||||||
|
let dropped = Rc::new(Cell::new(false));
|
||||||
|
{
|
||||||
|
let dropped = dropped.clone();
|
||||||
|
rsc.register_event(
|
||||||
|
draggable_weak,
|
||||||
|
CursorSense::click_or_drag() | CursorSense::unclick() | CursorSense::Drop,
|
||||||
|
move |ctx, rsc| match ctx.data.sense {
|
||||||
|
CursorSense::PressStart(_) | CursorSense::Pressing(_) => {
|
||||||
|
// Any committed drag takes capture -- a real caller
|
||||||
|
// would gate this on a `DragArbiter`/`DragGesture`
|
||||||
|
// decision, but this test only needs to exercise the
|
||||||
|
// capture-and-release mechanics themselves.
|
||||||
|
ctx.data.pointer.capture(draggable_weak.id());
|
||||||
|
let _ = rsc;
|
||||||
|
}
|
||||||
|
CursorSense::Drop => dropped.set(true),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((100.0, 100.0));
|
||||||
|
render.update(&draggable, &mut rsc);
|
||||||
|
|
||||||
|
let mut state = ();
|
||||||
|
let mut press = cursor_at((5.0, 5.0).into());
|
||||||
|
press.buttons.left = ActivationState::Start;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, press, (100.0, 100.0).into());
|
||||||
|
render.update(&draggable, &mut rsc);
|
||||||
|
assert_eq!(
|
||||||
|
pointer_input(&mut rsc).holder(),
|
||||||
|
Some(draggable.id()),
|
||||||
|
"the press should have taken capture"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The release lands nowhere near the widget's own region -- the exact
|
||||||
|
// shape of a fast fling's `ACTION_UP`.
|
||||||
|
let mut release = cursor_at((95.0, 95.0).into());
|
||||||
|
release.buttons.left = ActivationState::End;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, release, (100.0, 100.0).into());
|
||||||
|
render.update(&draggable, &mut rsc);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
dropped.get(),
|
||||||
|
"a release outside every widget's hit region must still reach \
|
||||||
|
the widget holding pointer capture"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pointer_input(&mut rsc).holder(),
|
||||||
|
None,
|
||||||
|
"Drop must release the capture"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A widget that never registers `CursorSense::Drop` at all must not be
|
||||||
|
/// affected by someone else's capture -- capture is per-gesture, not
|
||||||
|
/// global suppression of the whole input system for widgets that were
|
||||||
|
/// never party to it. (Practically this matters because a captured
|
||||||
|
/// widget's registration list still has to include `Drop` for `should_run`
|
||||||
|
/// to ever match it; this pins that half of the contract.)
|
||||||
|
#[test]
|
||||||
|
fn capturing_one_widget_starves_every_other_widget_of_events() {
|
||||||
|
let mut rsc = SenseRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let a = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let a_weak = a.weak();
|
||||||
|
let b = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED));
|
||||||
|
let b_weak = b.weak();
|
||||||
|
|
||||||
|
let b_hovered = Rc::new(Cell::new(false));
|
||||||
|
{
|
||||||
|
let b_hovered = b_hovered.clone();
|
||||||
|
rsc.register_event(b_weak, CursorSense::Hovering, move |_ctx, _rsc| {
|
||||||
|
b_hovered.set(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.add_strong(Stack {
|
||||||
|
children: vec![a.any(), b.any()],
|
||||||
|
size: StackSize::default(),
|
||||||
|
})
|
||||||
|
.any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((100.0, 100.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
pointer_input(&mut rsc).set_holder(Some(a_weak.id()));
|
||||||
|
|
||||||
|
let mut state = ();
|
||||||
|
let cursor = cursor_at((50.0, 50.0).into());
|
||||||
|
render.run_sensors(&mut rsc, &mut state, cursor, (100.0, 100.0).into());
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!b_hovered.get(),
|
||||||
|
"while a's drag holds capture, b must see no hover at all"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// IRIS_TODO.md's "the composer has no touch-drag scroll": `ScrollArea` only
|
||||||
|
/// answered a wheel, so a finger drag over overflowed text did nothing.
|
||||||
|
/// End-to-end over the real wiring -- `scrollable()`'s own registration,
|
||||||
|
/// `run_sensors`' dispatch, `ScrollController::drag`, `DragGesture`'s arbitration and
|
||||||
|
/// pointer capture -- rather than only `ScrollController::drag`'s own unit tests in
|
||||||
|
/// `scroll.rs`, because the registration is exactly the half those cannot
|
||||||
|
/// see.
|
||||||
|
#[test]
|
||||||
|
fn a_finger_drag_over_a_scroll_area_pans_it() {
|
||||||
|
let mut rsc = SenseRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1000px of content in a 100px window: room to pan.
|
||||||
|
let scroll_strong = rect(UiColor::WHITE)
|
||||||
|
.height(Len::abs(1000.0))
|
||||||
|
.scrollable(Axis::Y, Pin::Start)
|
||||||
|
.add_strong(&mut rsc);
|
||||||
|
let scroll = scroll_strong.weak();
|
||||||
|
let root = scroll_strong.any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((100.0, 100.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
// `ScrollArea` reads its content length back from the draw it just did, so
|
||||||
|
// the frame after is the first one that knows there is anything to pan
|
||||||
|
// -- the one-frame lag LAYOUT.md section 4 documents. `scroll(0.0)` is
|
||||||
|
// how `layout_tests.rs` asks for that second frame, and it also drops
|
||||||
|
// `snap_end`, leaving this parked at the start of the content.
|
||||||
|
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
assert_eq!(rsc.ui.widgets.get(&scroll).unwrap().amt(), 0.0);
|
||||||
|
|
||||||
|
let mut state = ();
|
||||||
|
let mut down = cursor_at((50.0, 80.0).into());
|
||||||
|
down.buttons.left = ActivationState::Start;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, down, (100.0, 100.0).into());
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
assert_eq!(
|
||||||
|
rsc.ui.widgets.get(&scroll).unwrap().amt(),
|
||||||
|
0.0,
|
||||||
|
"the touch-down alone must not move anything"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Inside the slop: still a tap as far as anything can tell.
|
||||||
|
let mut nudge = cursor_at((50.0, 80.0 - (DRAG_SLOP - 1.0)).into());
|
||||||
|
nudge.buttons.left = ActivationState::On;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, nudge, (100.0, 100.0).into());
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
assert_eq!(
|
||||||
|
rsc.ui.widgets.get(&scroll).unwrap().amt(),
|
||||||
|
0.0,
|
||||||
|
"a press inside DRAG_SLOP must not scroll"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Past it, upward: the content follows the finger up, which for this
|
||||||
|
// widget means more `amt`.
|
||||||
|
let mut drag = cursor_at((50.0, 80.0 - (DRAG_SLOP + 40.0)).into());
|
||||||
|
drag.buttons.left = ActivationState::On;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, drag, (100.0, 100.0).into());
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
let after = rsc.ui.widgets.get(&scroll).unwrap().amt();
|
||||||
|
assert!(
|
||||||
|
(after - 40.0).abs() < 0.01,
|
||||||
|
"expected the 40px past the slop to pan it, got {after}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the gesture holds the pointer, so the rest of it reaches this
|
||||||
|
// widget even once the finger leaves its box.
|
||||||
|
assert_eq!(pointer_input(&mut rsc).holder(), Some(scroll.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// docs/REVIEW-2026-09-07.md's D4. The first `MotionEvent` a view sees can
|
||||||
|
/// be a `Move` -- the `Down` went to another view, or the view was attached
|
||||||
|
/// mid-gesture -- and its batched samples are older than its own
|
||||||
|
/// timestamp. Anchoring on that timestamp clamped every one of them onto
|
||||||
|
/// the anchor, so the tracker saw three samples at one instant, the Lsq2
|
||||||
|
/// fit went degenerate, and the flick read 0 px/s.
|
||||||
|
#[test]
|
||||||
|
fn the_first_events_batched_samples_are_dated_apart() {
|
||||||
|
const MS: i64 = 1_000_000;
|
||||||
|
let now = Instant::now();
|
||||||
|
// A 120Hz batch: three historical samples at 0/4/8ms and the event's
|
||||||
|
// own at 12ms.
|
||||||
|
let clock = PointerClock::anchored(now, 12 * MS, 0);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
clock.at(12 * MS),
|
||||||
|
now,
|
||||||
|
"the event's own sample is the one that arrived now"
|
||||||
|
);
|
||||||
|
let batch = [clock.at(0), clock.at(4 * MS), clock.at(8 * MS)];
|
||||||
|
assert!(
|
||||||
|
batch[0] < batch[1] && batch[1] < batch[2] && batch[2] < now,
|
||||||
|
"the batch must keep the 4ms between its samples, got {:?}",
|
||||||
|
batch
|
||||||
|
.iter()
|
||||||
|
.map(|t| now.duration_since(*t))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
assert_eq!(clock.ms_since_anchor(8 * MS), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same clock has to keep ordering *across* events: the sample it
|
||||||
|
/// compares a new event's first sample against is the previous event's
|
||||||
|
/// last one, never the anchor.
|
||||||
|
#[test]
|
||||||
|
fn the_clock_orders_samples_across_events() {
|
||||||
|
const MS: i64 = 1_000_000;
|
||||||
|
let mut clock = PointerClock::anchored(Instant::now(), 12 * MS, 0);
|
||||||
|
let first = clock.sample(12 * MS);
|
||||||
|
let second = clock.sample(28 * MS);
|
||||||
|
assert!(second > first);
|
||||||
|
assert_eq!(
|
||||||
|
second.duration_since(first),
|
||||||
|
std::time::Duration::from_millis(16)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iris's 2026-09-08 phone report, first half: "it keeps snapping back to
|
||||||
|
/// some position when horizontally scrolling."
|
||||||
|
///
|
||||||
|
/// A `ScrollArea` that has committed to a pan holds the pointer, so the
|
||||||
|
/// gesture's end arrives as `CursorSense::Drop` -- and `scrollable`
|
||||||
|
/// used to register `click_or_drag | unclick` only, which `should_run`
|
||||||
|
/// never matches a `Drop` against. So the widget never learned its own
|
||||||
|
/// gesture had ended: its `DragArbiter` stayed `Panning` at the position
|
||||||
|
/// the finger left, and the *next* drag's first frame was measured from
|
||||||
|
/// there and applied in one step. The registration is
|
||||||
|
/// `CursorSense::drag_senses()` now, which is the rule for every widget
|
||||||
|
/// driving a `DragGesture` rather than a fact about this one.
|
||||||
|
#[test]
|
||||||
|
fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() {
|
||||||
|
let mut rsc = SenseRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
let scroll_strong = rect(UiColor::WHITE)
|
||||||
|
.height(Len::abs(1000.0))
|
||||||
|
.scrollable(Axis::Y, Pin::Start)
|
||||||
|
.add_strong(&mut rsc);
|
||||||
|
let scroll = scroll_strong.weak();
|
||||||
|
let root = scroll_strong.any();
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((100.0, 100.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
let mut state = ();
|
||||||
|
let win = Vec2::new(100.0, 100.0);
|
||||||
|
let mut send = |render: &mut UiRenderState, rsc: &mut SenseRsc, y: f32, button| {
|
||||||
|
let mut c = cursor_at((50.0, y).into());
|
||||||
|
c.buttons.left = button;
|
||||||
|
render.run_sensors(rsc, &mut state, c, win);
|
||||||
|
render.update(&root, rsc);
|
||||||
|
};
|
||||||
|
|
||||||
|
// One pan of 40px past the slop, then a release well outside the
|
||||||
|
// widget -- the ordinary shape of a flick.
|
||||||
|
send(&mut render, &mut rsc, 80.0, ActivationState::Start);
|
||||||
|
send(
|
||||||
|
&mut render,
|
||||||
|
&mut rsc,
|
||||||
|
80.0 - (DRAG_SLOP + 40.0),
|
||||||
|
ActivationState::On,
|
||||||
|
);
|
||||||
|
let after_first = rsc.ui.widgets.get(&scroll).unwrap().amt();
|
||||||
|
assert!((after_first - 40.0).abs() < 0.01, "amt={after_first}");
|
||||||
|
send(&mut render, &mut rsc, 400.0, ActivationState::End);
|
||||||
|
assert_eq!(
|
||||||
|
pointer_input(&mut rsc).holder(),
|
||||||
|
None,
|
||||||
|
"the release must give the pointer back"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A second gesture, starting where the first one did. If the arbiter
|
||||||
|
// were still panning from the release position, this first frame
|
||||||
|
// would apply the whole distance between the two at once.
|
||||||
|
send(&mut render, &mut rsc, 80.0, ActivationState::Start);
|
||||||
|
let after_second = rsc.ui.widgets.get(&scroll).unwrap().amt();
|
||||||
|
assert!(
|
||||||
|
(after_second - after_first).abs() < 0.01,
|
||||||
|
"a fresh touch-down moved the content by {} -- the previous \
|
||||||
|
gesture was never closed",
|
||||||
|
after_second - after_first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The second half of the same report: "tapping sometimes seems to make
|
||||||
|
/// the scrolling jump, particularly when tapping on things that have
|
||||||
|
/// events like horizontal scrolling."
|
||||||
|
///
|
||||||
|
/// Two widgets see the same press -- a scroll area and, under it,
|
||||||
|
/// something tracking the gesture for a list. When the scroll area
|
||||||
|
/// captures, the other one is cut off completely: no `PressEnd`, no
|
||||||
|
/// `Drop`. It has to be told, or its gesture stays open at an origin
|
||||||
|
/// belonging to a finger that has long gone, and the next unrelated touch
|
||||||
|
/// is measured from it.
|
||||||
|
#[test]
|
||||||
|
fn taking_the_pointer_cancels_everyone_else_tracking_the_press() {
|
||||||
|
let mut rsc = SenseRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// The bystander *contains* the capturer, which is the real shape: a
|
||||||
|
// transcript's `LazySpan` and one row's own text both track the same
|
||||||
|
// press, and a `Stack`'s siblings would be on separate layers where
|
||||||
|
// only the topmost is dispatched to at all.
|
||||||
|
let capturer = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
|
||||||
|
let capturer_weak = capturer.weak();
|
||||||
|
let bystander = rsc.ui.widgets.add_strong(Stack {
|
||||||
|
children: vec![capturer.any()],
|
||||||
|
size: StackSize::default(),
|
||||||
|
});
|
||||||
|
let bystander_weak = bystander.weak();
|
||||||
|
|
||||||
|
let capturer_saw = Rc::new(Cell::new(0u32));
|
||||||
|
{
|
||||||
|
let capturer_saw = capturer_saw.clone();
|
||||||
|
rsc.register_event(
|
||||||
|
capturer_weak,
|
||||||
|
CursorSense::drag_senses(),
|
||||||
|
move |ctx, _rsc| {
|
||||||
|
capturer_saw.set(capturer_saw.get() + 1);
|
||||||
|
if matches!(ctx.data.sense, CursorSense::Pressing(_)) {
|
||||||
|
ctx.data.pointer.capture(capturer_weak.id());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let cancelled = Rc::new(Cell::new(0u32));
|
||||||
|
let ended = Rc::new(Cell::new(0u32));
|
||||||
|
{
|
||||||
|
let (cancelled, ended) = (cancelled.clone(), ended.clone());
|
||||||
|
rsc.register_event(
|
||||||
|
bystander_weak,
|
||||||
|
CursorSense::drag_senses(),
|
||||||
|
move |ctx, _rsc| match ctx.data.sense {
|
||||||
|
CursorSense::Cancel => cancelled.set(cancelled.get() + 1),
|
||||||
|
CursorSense::PressEnd(_) | CursorSense::Drop => ended.set(ended.get() + 1),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = bystander.any();
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((100.0, 100.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
let mut state = ();
|
||||||
|
let win = Vec2::new(100.0, 100.0);
|
||||||
|
let mut down = cursor_at((50.0, 50.0).into());
|
||||||
|
down.buttons.left = ActivationState::Start;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, down, win);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
assert_eq!(cancelled.get(), 0, "nothing has captured yet");
|
||||||
|
|
||||||
|
let mut moved = cursor_at((50.0, 20.0).into());
|
||||||
|
moved.buttons.left = ActivationState::On;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, moved, win);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
assert!(capturer_saw.get() > 0, "the capturer never saw the press");
|
||||||
|
assert_eq!(
|
||||||
|
pointer_input(&mut rsc).holder(),
|
||||||
|
Some(capturer_weak.id()),
|
||||||
|
"the capture should have been taken on this frame"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cancelled.get(),
|
||||||
|
1,
|
||||||
|
"the widget that lost the gesture must be told exactly once"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And exactly once: the frames after the capture reach the capturer
|
||||||
|
// alone, so there is nothing left to cancel.
|
||||||
|
let mut more = cursor_at((50.0, 10.0).into());
|
||||||
|
more.buttons.left = ActivationState::On;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, more, win);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
let mut up = cursor_at((50.0, 10.0).into());
|
||||||
|
up.buttons.left = ActivationState::End;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, up, win);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
assert_eq!(cancelled.get(), 1, "cancelled more than once");
|
||||||
|
assert_eq!(
|
||||||
|
ended.get(),
|
||||||
|
0,
|
||||||
|
"a cancelled widget must not also be told the gesture ended \
|
||||||
|
normally -- acting on that is the tap it never made"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iris's rule for nested scrolling, 2026-09-08: "it should only trigger
|
||||||
|
/// horizontal if you drag left or right, and vertical should fall through
|
||||||
|
/// if you drag up or down."
|
||||||
|
///
|
||||||
|
/// One mechanism does both, and it is `DragArbiter`'s existing axis test:
|
||||||
|
/// each scroll area's gesture commits only on its own axis, so a drag
|
||||||
|
/// along the other one is never claimed and the enclosing area's gesture
|
||||||
|
/// -- which sees the same press, being an ancestor rather than a sibling
|
||||||
|
/// layer -- is the one that commits and captures. This pins the pair,
|
||||||
|
/// including the direction the change had no reason to touch.
|
||||||
|
#[test]
|
||||||
|
fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() {
|
||||||
|
for (name, to, pans, still) in [
|
||||||
|
("vertical", Vec2::new(50.0, 80.0 - (DRAG_SLOP + 40.0)), 0, 1),
|
||||||
|
(
|
||||||
|
"horizontal",
|
||||||
|
Vec2::new(50.0 - (DRAG_SLOP + 40.0), 80.0),
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut rsc = SenseRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
// 1000px square of content in a 100px window: room to pan either
|
||||||
|
// way, in an X area inside a Y one.
|
||||||
|
let seen = Rc::new(Cell::new(None));
|
||||||
|
let record = seen.clone();
|
||||||
|
let outer_strong = rect(UiColor::WHITE)
|
||||||
|
.width(Len::abs(1000.0))
|
||||||
|
.height(Len::abs(1000.0))
|
||||||
|
.scrollable(Axis::X, Pin::Start)
|
||||||
|
// The inner area's own handle, taken as the chain is built --
|
||||||
|
// the whole point is to exercise `scrollable`'s real
|
||||||
|
// registration on both, so neither is assembled by hand.
|
||||||
|
.with_id(move |_rsc, id| {
|
||||||
|
record.set(Some(id));
|
||||||
|
id
|
||||||
|
})
|
||||||
|
.scrollable(Axis::Y, Pin::Start)
|
||||||
|
.add_strong(&mut rsc);
|
||||||
|
let inner = seen.get().unwrap();
|
||||||
|
let outer = outer_strong.weak();
|
||||||
|
let root = outer_strong.any();
|
||||||
|
let areas = [outer, inner];
|
||||||
|
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((100.0, 100.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
// The second frame, where each area knows its content length --
|
||||||
|
// LAYOUT.md section 4's one-frame lag, and what drops `snap_end`.
|
||||||
|
for a in areas {
|
||||||
|
rsc.ui.widgets.get_mut(&a).unwrap().scroll(0.0);
|
||||||
|
}
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
let mut state = ();
|
||||||
|
let win = Vec2::new(100.0, 100.0);
|
||||||
|
let mut down = cursor_at((50.0, 80.0).into());
|
||||||
|
down.buttons.left = ActivationState::Start;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, down, win);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
let mut drag = cursor_at(to);
|
||||||
|
drag.buttons.left = ActivationState::On;
|
||||||
|
render.run_sensors(&mut rsc, &mut state, drag, win);
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
let moved = rsc.ui.widgets.get(&areas[pans]).unwrap().amt();
|
||||||
|
let unmoved = rsc.ui.widgets.get(&areas[still]).unwrap().amt();
|
||||||
|
assert!(
|
||||||
|
(moved - 40.0).abs() < 0.01,
|
||||||
|
"a {name} drag should have panned the {name} area by the 40px \
|
||||||
|
past the slop, got {moved}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
unmoved, 0.0,
|
||||||
|
"a {name} drag must not move the area that owns the other axis"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iris's 2026-09-08 report: "if I try to scroll vertically while a
|
||||||
|
/// horizontal scroll animation is still active, it stays locked to the
|
||||||
|
/// horizontal scroll", with her own diagnosis -- "tapping outside of
|
||||||
|
/// something that a fling is currently active for should have no code in
|
||||||
|
/// common with the fling that could influence it."
|
||||||
|
///
|
||||||
|
/// She was right that it was global state, and this is where it lived.
|
||||||
|
/// `run_sensors` runs a widget one more frame *after* the pointer has
|
||||||
|
/// left it, so a `HoverEnd` can fire ([`ActivationState::End`], which is
|
||||||
|
/// not `Off`) -- and `should_run` derived a press from the button alone,
|
||||||
|
/// so that farewell frame also carried a `PressStart`. A widget nowhere
|
||||||
|
/// near the finger therefore opened a gesture, and a `ScrollArea` catching
|
||||||
|
/// its own fling commits with no slop, so it captured the pointer and the
|
||||||
|
/// whole gesture went to it.
|
||||||
|
///
|
||||||
|
/// Two areas side by side here rather than one, because "the press went
|
||||||
|
/// to the wrong widget" and "the press went nowhere" are different
|
||||||
|
/// failures and only the second area can tell them apart.
|
||||||
|
#[test]
|
||||||
|
fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() {
|
||||||
|
let mut rsc = SenseRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
events: EventManager::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Two 1000px-tall scroll areas, stacked: the top half of the window
|
||||||
|
// is the first, the bottom half the second. Each area's own handle is
|
||||||
|
// taken as its chain is built (`with_id`, the same way the nested-axes
|
||||||
|
// test above does it), since what is under test is `scrollable()`'s
|
||||||
|
// real registration rather than a `ScrollArea` assembled by hand.
|
||||||
|
let seen: [Rc<Cell<Option<WeakWidget<ScrollArea>>>>; 2] = Default::default();
|
||||||
|
let half = |slot: &Rc<Cell<Option<WeakWidget<ScrollArea>>>>| {
|
||||||
|
let record = slot.clone();
|
||||||
|
rect(UiColor::WHITE)
|
||||||
|
.height(Len::abs(1000.0))
|
||||||
|
.scrollable(Axis::Y, Pin::Start)
|
||||||
|
.with_id(move |_rsc, id| {
|
||||||
|
record.set(Some(id));
|
||||||
|
id
|
||||||
|
})
|
||||||
|
.height(Len::rel(0.5))
|
||||||
|
};
|
||||||
|
let root = (half(&seen[0]), half(&seen[1]))
|
||||||
|
.span(Dir::DOWN)
|
||||||
|
.add_strong(&mut rsc)
|
||||||
|
.any();
|
||||||
|
let (top_w, bottom_w) = (seen[0].get().unwrap(), seen[1].get().unwrap());
|
||||||
|
|
||||||
|
let win: Vec2 = (100.0, 200.0).into();
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((win.x, win.y));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
// The second frame is the first that knows how long the content is --
|
||||||
|
// see `a_finger_drag_over_a_scroll_area_pans_it`.
|
||||||
|
for w in [&top_w, &bottom_w] {
|
||||||
|
rsc.ui.widgets.get_mut(w).unwrap().scroll(0.0);
|
||||||
|
}
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
let mut state = ();
|
||||||
|
// Flick the top area and let go: it is left flinging, and -- because
|
||||||
|
// the release goes through `run_sensors`' capture branch, which
|
||||||
|
// returns before the loop that would have updated anybody's hover --
|
||||||
|
// its sensor is left `On` with the pointer no longer on it. Both
|
||||||
|
// halves of the real gesture, since both are what the bug needs.
|
||||||
|
let base = Instant::now();
|
||||||
|
let mut t = 0;
|
||||||
|
let sample = |render: &mut UiRenderState,
|
||||||
|
rsc: &mut SenseRsc,
|
||||||
|
state: &mut (),
|
||||||
|
y: f32,
|
||||||
|
button: ActivationState,
|
||||||
|
at_ms: u64| {
|
||||||
|
let mut c = cursor_at((50.0, y).into());
|
||||||
|
c.buttons.left = button;
|
||||||
|
c.time = base + std::time::Duration::from_millis(at_ms);
|
||||||
|
render.run_sensors(rsc, state, c, win);
|
||||||
|
render.update(&root, rsc);
|
||||||
|
};
|
||||||
|
sample(
|
||||||
|
&mut render,
|
||||||
|
&mut rsc,
|
||||||
|
&mut state,
|
||||||
|
50.0,
|
||||||
|
ActivationState::Start,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
for y in [44.0, 32.0, 14.0] {
|
||||||
|
t += 8;
|
||||||
|
sample(&mut render, &mut rsc, &mut state, y, ActivationState::On, t);
|
||||||
|
}
|
||||||
|
t += 8;
|
||||||
|
sample(
|
||||||
|
&mut render,
|
||||||
|
&mut rsc,
|
||||||
|
&mut state,
|
||||||
|
14.0,
|
||||||
|
ActivationState::End,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rsc.ui.widgets.get(&top_w).unwrap().is_scrolling(),
|
||||||
|
"the flick must leave the top area coasting -- the press below is \
|
||||||
|
only dangerous while something is still moving",
|
||||||
|
);
|
||||||
|
let flung_to = rsc.ui.widgets.get(&top_w).unwrap().amt();
|
||||||
|
|
||||||
|
// Now press and drag in the *bottom* area: the top area's hover
|
||||||
|
// decays to `End` on this very sample, which is the frame that used
|
||||||
|
// to carry a `PressStart` to it.
|
||||||
|
t += 8;
|
||||||
|
sample(
|
||||||
|
&mut render,
|
||||||
|
&mut rsc,
|
||||||
|
&mut state,
|
||||||
|
150.0,
|
||||||
|
ActivationState::Start,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
t += 8;
|
||||||
|
sample(
|
||||||
|
&mut render,
|
||||||
|
&mut rsc,
|
||||||
|
&mut state,
|
||||||
|
150.0 - (DRAG_SLOP + 40.0),
|
||||||
|
ActivationState::On,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
|
||||||
|
let moved = rsc.ui.widgets.get(&bottom_w).unwrap().amt();
|
||||||
|
assert!(
|
||||||
|
(moved - 40.0).abs() < 0.01,
|
||||||
|
"the area actually under the finger should have panned by the 40px \
|
||||||
|
past the slop, got {moved}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rsc.ui.widgets.get(&top_w).unwrap().amt(),
|
||||||
|
flung_to,
|
||||||
|
"the area the pointer had left must not have seen the press at all -- \
|
||||||
|
a catch would have stopped its fling on the touch-down"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pointer_input(&mut rsc).holder(),
|
||||||
|
Some(bottom_w.id()),
|
||||||
|
"the gesture belongs to the widget under the finger",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
use iris_core::{
|
use iris_core::{
|
||||||
WidgetId,
|
UiRsc, WidgetId,
|
||||||
util::{HashMap, HashSet},
|
util::{HashMap, HashSet},
|
||||||
};
|
};
|
||||||
|
use iris_core::{WeakWidget, Widget};
|
||||||
use std::{
|
use std::{
|
||||||
any::{Any, TypeId},
|
any::{Any, TypeId},
|
||||||
marker::PhantomData,
|
marker::PhantomData,
|
||||||
@@ -73,3 +74,39 @@ impl<'a, T: 'static> FnOnce<(&'a mut WidgetState,)> for WeakState<T> {
|
|||||||
state.get_mut(self)
|
state.get_mut(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What `Rsc[weak_handle]` indexes through -- one impl per kind of handle
|
||||||
|
/// (a widget, a piece of per-widget state), shared by both backends' `Rsc`
|
||||||
|
/// types since indexing a widget tree has nothing to do with windowing.
|
||||||
|
/// Each backend still needs its own `Index`/`IndexMut for ItsRsc<State>`
|
||||||
|
/// (`default/mod.rs`, `android/view.rs`), because a blanket impl over every
|
||||||
|
/// `I: RscIdx<Rsc>` for every possible `Rsc` would conflict between crates.
|
||||||
|
pub trait RscIdx<Rsc> {
|
||||||
|
type Output;
|
||||||
|
fn get(self, rsc: &Rsc) -> &Self::Output;
|
||||||
|
fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W: Widget, Rsc: UiRsc> RscIdx<Rsc> for WeakWidget<W> {
|
||||||
|
type Output = W;
|
||||||
|
|
||||||
|
fn get(self, rsc: &Rsc) -> &Self::Output {
|
||||||
|
&rsc.ui().widgets[self]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output {
|
||||||
|
&mut rsc.ui_mut().widgets[self]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: 'static, Rsc: HasWidgetState> RscIdx<Rsc> for WeakState<T> {
|
||||||
|
type Output = T;
|
||||||
|
|
||||||
|
fn get(self, rsc: &Rsc) -> &Self::Output {
|
||||||
|
rsc.widget_state().get(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output {
|
||||||
|
rsc.widget_state_mut().get_mut(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,17 @@ use tokio::{
|
|||||||
unbounded_channel as async_channel,
|
unbounded_channel as async_channel,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use winit::window::Window;
|
|
||||||
|
/// What a completed task nudges when it wants its result drawn. Shared
|
||||||
|
/// between backends rather than typed as `winit::window::Window` directly:
|
||||||
|
/// android-view has no `Window` at all, and the redraw request there is a
|
||||||
|
/// JNI call (`View::post_frame_callback`) rather than a method call on a
|
||||||
|
/// value this crate owns. Each backend supplies its own implementation --
|
||||||
|
/// `default/render.rs` for winit, `android/render.rs` for android-view --
|
||||||
|
/// and this module never needs to know which one it is holding.
|
||||||
|
pub trait RequestRedraw: Send + Sync + 'static {
|
||||||
|
fn request_redraw(&self);
|
||||||
|
}
|
||||||
|
|
||||||
pub type TaskMsgSender<Rsc> = SyncSender<Box<dyn TaskUpdate<Rsc>>>;
|
pub type TaskMsgSender<Rsc> = SyncSender<Box<dyn TaskUpdate<Rsc>>>;
|
||||||
pub type TaskMsgReceiver<Rsc> = SyncReceiver<Box<dyn TaskUpdate<Rsc>>>;
|
pub type TaskMsgReceiver<Rsc> = SyncReceiver<Box<dyn TaskUpdate<Rsc>>>;
|
||||||
@@ -23,7 +33,7 @@ impl<F: FnOnce(&mut Rsc::State, &mut Rsc) + Send, Rsc: HasState> TaskUpdate<Rsc>
|
|||||||
|
|
||||||
pub struct Tasks<Rsc: HasState> {
|
pub struct Tasks<Rsc: HasState> {
|
||||||
start: AsyncSender<BoxTask>,
|
start: AsyncSender<BoxTask>,
|
||||||
window: Arc<Window>,
|
redraw: Arc<dyn RequestRedraw>,
|
||||||
msg_send: SyncSender<Box<dyn TaskUpdate<Rsc>>>,
|
msg_send: SyncSender<Box<dyn TaskUpdate<Rsc>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +55,7 @@ impl<Rsc: HasState + 'static> TaskCtx<Rsc> {
|
|||||||
type BoxTask = Pin<Box<dyn Future<Output = ()> + Send>>;
|
type BoxTask = Pin<Box<dyn Future<Output = ()> + Send>>;
|
||||||
|
|
||||||
impl<Rsc: HasState> Tasks<Rsc> {
|
impl<Rsc: HasState> Tasks<Rsc> {
|
||||||
pub fn init(window: Arc<Window>) -> (Self, TaskMsgReceiver<Rsc>) {
|
pub fn init(redraw: Arc<dyn RequestRedraw>) -> (Self, TaskMsgReceiver<Rsc>) {
|
||||||
let (start, start_recv) = async_channel();
|
let (start, start_recv) = async_channel();
|
||||||
let (msgs, msgs_recv) = sync_channel();
|
let (msgs, msgs_recv) = sync_channel();
|
||||||
std::thread::spawn(|| {
|
std::thread::spawn(|| {
|
||||||
@@ -56,21 +66,33 @@ impl<Rsc: HasState> Tasks<Rsc> {
|
|||||||
Self {
|
Self {
|
||||||
start,
|
start,
|
||||||
msg_send: msgs,
|
msg_send: msgs,
|
||||||
window,
|
redraw,
|
||||||
},
|
},
|
||||||
msgs_recv,
|
msgs_recv,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same redraw handle `spawn`'s wrapper calls once, after a whole
|
||||||
|
/// task's future completes -- exposed so a caller running its own
|
||||||
|
/// longer-lived loop *inside* a spawned task (a live SSE follow, here)
|
||||||
|
/// can ask for a frame after each `TaskCtx::update`, not just at the
|
||||||
|
/// end. Without this a caller has no way to get a redraw mid-stream,
|
||||||
|
/// which is exactly the gap `iris/desktop-app`'s `app.rs` module doc
|
||||||
|
/// names for why it uses winit's `Proxy` instead of `Tasks` -- Android
|
||||||
|
/// has no `Proxy`, so this is what closes the same gap there.
|
||||||
|
pub fn redraw_handle(&self) -> Arc<dyn RequestRedraw> {
|
||||||
|
self.redraw.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn spawn<F: AsyncFnOnce(TaskCtx<Rsc>) + 'static + std::marker::Send>(&mut self, task: F)
|
pub fn spawn<F: AsyncFnOnce(TaskCtx<Rsc>) + 'static + std::marker::Send>(&mut self, task: F)
|
||||||
where
|
where
|
||||||
F::CallOnceFuture: Send,
|
F::CallOnceFuture: Send,
|
||||||
{
|
{
|
||||||
let send = self.msg_send.clone();
|
let send = self.msg_send.clone();
|
||||||
let window = self.window.clone();
|
let redraw = self.redraw.clone();
|
||||||
let _ = self.start.send(Box::pin(async move {
|
let _ = self.start.send(Box::pin(async move {
|
||||||
task(TaskCtx::new(send)).await;
|
task(TaskCtx::new(send)).await;
|
||||||
window.request_redraw();
|
redraw.request_redraw();
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+11
-8
@@ -6,16 +6,19 @@ pub struct Image {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for Image {
|
impl Widget for Image {
|
||||||
fn draw(&mut self, painter: &mut Painter) {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
painter.texture(&self.handle);
|
// Drawn at its own natural size, anchored top-left of whatever it
|
||||||
|
// was offered, not stretched to fill it -- its primitive is
|
||||||
|
// independent of the offered region, matching `is_size_independent`
|
||||||
|
// below. A caller that wants it placed differently wraps it (e.g.
|
||||||
|
// `.center()`, `.align(...)`).
|
||||||
|
let size = self.handle.size();
|
||||||
|
painter.texture_within(&self.handle, size.align(Align::TOP_LEFT));
|
||||||
|
Size::abs(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn desired_width(&mut self, _: &mut SizeCtx) -> Len {
|
fn is_size_independent(&self) -> bool {
|
||||||
Len::abs(self.handle.size().x)
|
true // a decoded image's primitive never depends on the region it is offered
|
||||||
}
|
|
||||||
|
|
||||||
fn desired_height(&mut self, _: &mut SizeCtx) -> Len {
|
|
||||||
Len::abs(self.handle.size().y)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-11
@@ -1,20 +1,36 @@
|
|||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
/// Clips `inner` -- and everything below it -- to a shape.
|
||||||
|
///
|
||||||
|
/// The shape is a **primitive**, never a rectangle or a radius stored
|
||||||
|
/// here: with `shape`, the widget named there is drawn behind `inner`
|
||||||
|
/// filling the same box and the clip is its first primitive, so a rounded
|
||||||
|
/// container's corner and the corner its content is cut to are the same
|
||||||
|
/// arithmetic and cannot fall out of step. Without one, this writes an
|
||||||
|
/// undrawn rect at its own region, which is the plain "clip to my box"
|
||||||
|
/// every list and scroll area wants. See docs/LAYOUT.md's "Masks with a
|
||||||
|
/// shape".
|
||||||
pub struct Masked {
|
pub struct Masked {
|
||||||
|
/// The widget whose first primitive is the clip, drawn behind
|
||||||
|
/// `inner`, or `None` for this widget's own box.
|
||||||
|
pub shape: Option<StrongWidget>,
|
||||||
pub inner: StrongWidget,
|
pub inner: StrongWidget,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for Masked {
|
impl Widget for Masked {
|
||||||
fn draw(&mut self, painter: &mut Painter) {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
painter.set_mask(painter.region());
|
match &self.shape {
|
||||||
painter.widget(&self.inner);
|
// Layered the way `Stack` layers a background under its
|
||||||
}
|
// content, and for the same reason: within one layer the draw
|
||||||
|
// order is undefined once anything has been freed.
|
||||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
Some(shape) => {
|
||||||
ctx.width(&self.inner)
|
painter.child_layer();
|
||||||
}
|
painter.widget(shape);
|
||||||
|
painter.set_mask_to_widget(shape);
|
||||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
painter.next_layer();
|
||||||
ctx.height(&self.inner)
|
}
|
||||||
|
None => painter.set_mask(painter.region()),
|
||||||
|
}
|
||||||
|
painter.widget(&self.inner)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,30 +6,31 @@ pub struct Aligned {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for Aligned {
|
impl Widget for Aligned {
|
||||||
fn draw(&mut self, painter: &mut Painter) {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
|
// Draw once at the whole region this widget was offered to learn
|
||||||
|
// the child's real size -- this placement is provisional and
|
||||||
|
// corrected below without a second draw. `painter.widget` (not
|
||||||
|
// `widget_within(..., painter.region())`) is what "my whole,
|
||||||
|
// already-resolved region, unmodified" means: `widget_within`
|
||||||
|
// composes its argument as a *local*, `UiRegion::FULL`-relative
|
||||||
|
// box against `painter.region()`, so handing it the
|
||||||
|
// already-resolved region double-applies that composition and is
|
||||||
|
// wrong for any widget nested below the root.
|
||||||
|
let used = painter.widget(&self.inner);
|
||||||
|
let density = painter.density();
|
||||||
let region = match self.align.tuple() {
|
let region = match self.align.tuple() {
|
||||||
(Some(x), Some(y)) => painter
|
(Some(x), Some(y)) => used.to_uivec2(density).align(RegionAlign { x, y }),
|
||||||
.size(&self.inner)
|
|
||||||
.to_uivec2()
|
|
||||||
.align(RegionAlign { x, y }),
|
|
||||||
(Some(x), None) => {
|
(Some(x), None) => {
|
||||||
let x = painter.size_ctx().width(&self.inner).apply_rest().align(x);
|
let x = used.x.apply_rest(density).align(x);
|
||||||
UiRegion::new(x, UiSpan::FULL)
|
UiRegion::new(x, UiSpan::FULL)
|
||||||
}
|
}
|
||||||
(None, Some(y)) => {
|
(None, Some(y)) => {
|
||||||
let y = painter.size_ctx().height(&self.inner).apply_rest().align(y);
|
let y = used.y.apply_rest(density).align(y);
|
||||||
UiRegion::new(UiSpan::FULL, y)
|
UiRegion::new(UiSpan::FULL, y)
|
||||||
}
|
}
|
||||||
(None, None) => UiRegion::FULL,
|
(None, None) => UiRegion::FULL,
|
||||||
};
|
};
|
||||||
painter.widget_within(&self.inner, region);
|
painter.reposition(&self.inner, region); // O(1): one offset write, no second draw
|
||||||
}
|
used
|
||||||
|
|
||||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
|
||||||
ctx.width(&self.inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
|
||||||
ctx.height(&self.inner)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,18 +6,10 @@ pub struct LayerOffset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for LayerOffset {
|
impl Widget for LayerOffset {
|
||||||
fn draw(&mut self, painter: &mut Painter) {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
for _ in 0..self.offset {
|
for _ in 0..self.offset {
|
||||||
painter.next_layer();
|
painter.next_layer();
|
||||||
}
|
}
|
||||||
painter.widget(&self.inner);
|
painter.widget(&self.inner)
|
||||||
}
|
|
||||||
|
|
||||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
|
||||||
ctx.width(&self.inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
|
||||||
ctx.height(&self.inner)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -7,42 +7,57 @@ pub struct MaxSize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MaxSize {
|
impl MaxSize {
|
||||||
fn apply_to_outer(&self, ctx: &mut SizeCtx) {
|
/// Caps a reported length at `max`, comparing in pixels since `Len`'s
|
||||||
if let Some(x) = self.x {
|
/// rel/abs/rest components are not otherwise comparable.
|
||||||
ctx.outer.x.select_len(x.apply_rest());
|
fn clamp(len: Len, max: Option<Len>, output: f32, density: f32) -> Len {
|
||||||
|
let Some(max) = max else {
|
||||||
|
return len;
|
||||||
|
};
|
||||||
|
let len_px = len.apply_rest(density).to_abs(output);
|
||||||
|
let max_px = max.apply_rest(density).to_abs(output);
|
||||||
|
// `fold_dp`, not the caller's `max` as written: a reported `Len`
|
||||||
|
// may not carry an unresolved `dp` -- see `Len::fold_dp` for the
|
||||||
|
// collapsed composer bar this caused.
|
||||||
|
if len_px > max_px {
|
||||||
|
max.fold_dp(density)
|
||||||
|
} else {
|
||||||
|
len
|
||||||
}
|
}
|
||||||
if let Some(y) = self.y {
|
}
|
||||||
ctx.outer.y.select_len(y.apply_rest());
|
|
||||||
|
/// The span (in this widget's own local, `UiRegion::FULL`-relative
|
||||||
|
/// terms) to actually offer the child: unconstrained if it already fits
|
||||||
|
/// within `max`, or a box of exactly `max`, anchored at this axis's
|
||||||
|
/// start, if it does not. Needed so the child is never painted bigger
|
||||||
|
/// than the size this widget reports for it -- see the identical
|
||||||
|
/// requirement noted on `Sized::draw`.
|
||||||
|
fn clamp_region(offered_px: f32, max: Option<Len>, output: f32, density: f32) -> UiSpan {
|
||||||
|
let Some(max) = max else {
|
||||||
|
return UiSpan::FULL;
|
||||||
|
};
|
||||||
|
let max_scalar = max.apply_rest(density);
|
||||||
|
let max_px = max_scalar.to_abs(output);
|
||||||
|
if offered_px > max_px {
|
||||||
|
max_scalar.align(AxisAlign::Neg)
|
||||||
|
} else {
|
||||||
|
UiSpan::FULL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for MaxSize {
|
impl Widget for MaxSize {
|
||||||
fn draw(&mut self, painter: &mut Painter) {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
painter.widget(&self.inner);
|
let output = painter.output_size();
|
||||||
}
|
let density = painter.density();
|
||||||
|
let offered = painter.px_size();
|
||||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
let region = UiRegion {
|
||||||
self.apply_to_outer(ctx);
|
x: Self::clamp_region(offered.x, self.x, output.x, density),
|
||||||
let width = ctx.width(&self.inner);
|
y: Self::clamp_region(offered.y, self.y, output.y, density),
|
||||||
if let Some(x) = self.x {
|
};
|
||||||
let width_px = width.apply_rest().to_abs(ctx.output_size().x);
|
let used = painter.widget_within(&self.inner, region);
|
||||||
let x_px = x.apply_rest().to_abs(ctx.output_size().x);
|
Size {
|
||||||
if width_px > x_px { x } else { width }
|
x: Self::clamp(used.x, self.x, output.x, density),
|
||||||
} else {
|
y: Self::clamp(used.y, self.y, output.y, density),
|
||||||
width
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
|
||||||
self.apply_to_outer(ctx);
|
|
||||||
let height = ctx.height(&self.inner);
|
|
||||||
if let Some(y) = self.y {
|
|
||||||
let height_px = height.apply_rest().to_abs(ctx.output_size().y);
|
|
||||||
let y_px = y.apply_rest().to_abs(ctx.output_size().y);
|
|
||||||
if height_px > y_px { y } else { height }
|
|
||||||
} else {
|
|
||||||
height
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,23 @@
|
|||||||
mod align;
|
mod align;
|
||||||
mod layer;
|
mod layer;
|
||||||
|
mod lazy_span;
|
||||||
mod max_size;
|
mod max_size;
|
||||||
mod offset;
|
mod offset;
|
||||||
mod pad;
|
mod pad;
|
||||||
mod scroll;
|
mod scroll_area;
|
||||||
|
mod scrollable;
|
||||||
mod sized;
|
mod sized;
|
||||||
mod span;
|
mod span;
|
||||||
mod stack;
|
mod stack;
|
||||||
|
|
||||||
pub use align::*;
|
pub use align::*;
|
||||||
pub use layer::*;
|
pub use layer::*;
|
||||||
|
pub use lazy_span::*;
|
||||||
pub use max_size::*;
|
pub use max_size::*;
|
||||||
pub use offset::*;
|
pub use offset::*;
|
||||||
pub use pad::*;
|
pub use pad::*;
|
||||||
pub use scroll::*;
|
pub use scroll_area::*;
|
||||||
|
pub use scrollable::*;
|
||||||
pub use sized::*;
|
pub use sized::*;
|
||||||
pub use span::*;
|
pub use span::*;
|
||||||
pub use stack::*;
|
pub use stack::*;
|
||||||
@@ -6,16 +6,8 @@ pub struct Offset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for Offset {
|
impl Widget for Offset {
|
||||||
fn draw(&mut self, painter: &mut Painter) {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
let region = UiRegion::FULL.offset(self.amt);
|
let region = UiRegion::FULL.offset(self.amt);
|
||||||
painter.widget_within(&self.inner, region);
|
painter.widget_within(&self.inner, region)
|
||||||
}
|
|
||||||
|
|
||||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
|
||||||
ctx.width(&self.inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
|
||||||
ctx.height(&self.inner)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+62
-63
@@ -6,48 +6,43 @@ pub struct Pad {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for Pad {
|
impl Widget for Pad {
|
||||||
fn draw(&mut self, painter: &mut Painter) {
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
painter.widget_within(&self.inner, self.padding.region());
|
let density = painter.density();
|
||||||
}
|
let used = painter.widget_within(&self.inner, self.padding.region(density));
|
||||||
|
let width =
|
||||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
self.padding.left.apply_rest(density).abs + self.padding.right.apply_rest(density).abs;
|
||||||
let width = self.padding.left + self.padding.right;
|
let height =
|
||||||
let height = self.padding.top + self.padding.bottom;
|
self.padding.top.apply_rest(density).abs + self.padding.bottom.apply_rest(density).abs;
|
||||||
ctx.outer.x.abs -= width;
|
Size {
|
||||||
ctx.outer.y.abs -= height;
|
x: used.x + Len::abs(width),
|
||||||
let mut size = ctx.width(&self.inner);
|
y: used.y + Len::abs(height),
|
||||||
size.abs += width;
|
}
|
||||||
size
|
|
||||||
}
|
|
||||||
|
|
||||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
|
||||||
let width = self.padding.left + self.padding.right;
|
|
||||||
let height = self.padding.top + self.padding.bottom;
|
|
||||||
ctx.outer.x.abs -= width;
|
|
||||||
ctx.outer.y.abs -= height;
|
|
||||||
let mut size = ctx.height(&self.inner);
|
|
||||||
size.abs += height;
|
|
||||||
size
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Each side is a `Len`, not a bare `f32`, so `.pad(dp(10))` resolves
|
||||||
|
/// against the display's density the same way any other size does -- see
|
||||||
|
/// `Len::dp`'s field doc. `.pad(10)` (a bare number) still works via
|
||||||
|
/// `From<T: UiNum>` below, unchanged: it becomes an `abs` (physical-pixel)
|
||||||
|
/// `Len`, exactly as a bare number always has meant elsewhere in this
|
||||||
|
/// crate.
|
||||||
pub struct Padding {
|
pub struct Padding {
|
||||||
pub left: f32,
|
pub left: Len,
|
||||||
pub right: f32,
|
pub right: Len,
|
||||||
pub top: f32,
|
pub top: Len,
|
||||||
pub bottom: f32,
|
pub bottom: Len,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Padding {
|
impl Padding {
|
||||||
pub const ZERO: Self = Self {
|
pub const ZERO: Self = Self {
|
||||||
left: 0.0,
|
left: Len::ZERO,
|
||||||
right: 0.0,
|
right: Len::ZERO,
|
||||||
top: 0.0,
|
top: Len::ZERO,
|
||||||
bottom: 0.0,
|
bottom: Len::ZERO,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn uniform(amt: impl UiNum) -> Self {
|
pub fn uniform(amt: impl Into<Len>) -> Self {
|
||||||
let amt = amt.to_f32();
|
let amt = amt.into();
|
||||||
Self {
|
Self {
|
||||||
left: amt,
|
left: amt,
|
||||||
right: amt,
|
right: amt,
|
||||||
@@ -55,80 +50,84 @@ impl Padding {
|
|||||||
bottom: amt,
|
bottom: amt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn region(&self) -> UiRegion {
|
pub fn region(&self, density: f32) -> UiRegion {
|
||||||
let mut region = UiRegion::FULL;
|
let mut region = UiRegion::FULL;
|
||||||
region.x.start.abs += self.left;
|
region.x.start.abs += self.left.apply_rest(density).abs;
|
||||||
region.y.start.abs += self.top;
|
region.y.start.abs += self.top.apply_rest(density).abs;
|
||||||
region.x.end.abs -= self.right;
|
region.x.end.abs -= self.right.apply_rest(density).abs;
|
||||||
region.y.end.abs -= self.bottom;
|
region.y.end.abs -= self.bottom.apply_rest(density).abs;
|
||||||
region
|
region
|
||||||
}
|
}
|
||||||
pub fn x(amt: impl UiNum) -> Self {
|
pub fn x(amt: impl Into<Len>) -> Self {
|
||||||
let amt = amt.to_f32();
|
let amt = amt.into();
|
||||||
Self {
|
Self {
|
||||||
left: amt,
|
left: amt,
|
||||||
right: amt,
|
right: amt,
|
||||||
top: 0.0,
|
top: Len::ZERO,
|
||||||
bottom: 0.0,
|
bottom: Len::ZERO,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn y(amt: impl UiNum) -> Self {
|
pub fn y(amt: impl Into<Len>) -> Self {
|
||||||
let amt = amt.to_f32();
|
let amt = amt.into();
|
||||||
Self {
|
Self {
|
||||||
left: 0.0,
|
left: Len::ZERO,
|
||||||
right: 0.0,
|
right: Len::ZERO,
|
||||||
top: amt,
|
top: amt,
|
||||||
bottom: amt,
|
bottom: amt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn top(amt: impl UiNum) -> Self {
|
pub fn top(amt: impl Into<Len>) -> Self {
|
||||||
let mut s = Self::ZERO;
|
let mut s = Self::ZERO;
|
||||||
s.top = amt.to_f32();
|
s.top = amt.into();
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bottom(amt: impl UiNum) -> Self {
|
pub fn bottom(amt: impl Into<Len>) -> Self {
|
||||||
let mut s = Self::ZERO;
|
let mut s = Self::ZERO;
|
||||||
s.bottom = amt.to_f32();
|
s.bottom = amt.into();
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn left(amt: impl UiNum) -> Self {
|
pub fn left(amt: impl Into<Len>) -> Self {
|
||||||
let mut s = Self::ZERO;
|
let mut s = Self::ZERO;
|
||||||
s.left = amt.to_f32();
|
s.left = amt.into();
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn right(amt: impl UiNum) -> Self {
|
pub fn right(amt: impl Into<Len>) -> Self {
|
||||||
let mut s = Self::ZERO;
|
let mut s = Self::ZERO;
|
||||||
s.right = amt.to_f32();
|
s.right = amt.into();
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_top(mut self, amt: impl UiNum) -> Self {
|
pub fn with_top(mut self, amt: impl Into<Len>) -> Self {
|
||||||
self.top = amt.to_f32();
|
self.top = amt.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_bottom(mut self, amt: impl UiNum) -> Self {
|
pub fn with_bottom(mut self, amt: impl Into<Len>) -> Self {
|
||||||
self.bottom = amt.to_f32();
|
self.bottom = amt.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_left(mut self, amt: impl UiNum) -> Self {
|
pub fn with_left(mut self, amt: impl Into<Len>) -> Self {
|
||||||
self.left = amt.to_f32();
|
self.left = amt.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_right(mut self, amt: impl UiNum) -> Self {
|
pub fn with_right(mut self, amt: impl Into<Len>) -> Self {
|
||||||
self.right = amt.to_f32();
|
self.right = amt.into();
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: UiNum> From<T> for Padding {
|
/// Covers both a bare number (`.pad(8)`, via `Len`'s own `From<N: UiNum>`
|
||||||
|
/// blanket -- an `abs`/physical-pixel `Len`) and a `Len` directly
|
||||||
|
/// (`.pad(dp(10))`) with the one impl, since `Len: Into<Len>` is the
|
||||||
|
/// reflexive case of the same bound.
|
||||||
|
impl<T: Into<Len>> From<T> for Padding {
|
||||||
fn from(amt: T) -> Self {
|
fn from(amt: T) -> Self {
|
||||||
Self::uniform(amt.to_f32())
|
Self::uniform(amt.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
use crate::prelude::*;
|
|
||||||
|
|
||||||
pub struct Scroll {
|
|
||||||
inner: StrongWidget,
|
|
||||||
axis: Axis,
|
|
||||||
amt: f32,
|
|
||||||
snap_end: bool,
|
|
||||||
container_len: f32,
|
|
||||||
content_len: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Widget for Scroll {
|
|
||||||
fn draw(&mut self, painter: &mut Painter) {
|
|
||||||
let output_len = painter.output_size().axis(self.axis);
|
|
||||||
let container_len = painter.region().axis(self.axis).len();
|
|
||||||
let content_len = painter
|
|
||||||
.len_axis(&self.inner, self.axis)
|
|
||||||
.apply_rest()
|
|
||||||
.within_len(container_len)
|
|
||||||
.to_abs(output_len);
|
|
||||||
self.container_len = container_len.to_abs(output_len);
|
|
||||||
self.content_len = content_len;
|
|
||||||
|
|
||||||
if self.snap_end {
|
|
||||||
self.amt = self.content_len - self.container_len;
|
|
||||||
}
|
|
||||||
self.update_amt();
|
|
||||||
|
|
||||||
let mut region = UiRegion::FULL.offset(Vec2::from_axis(self.axis, -self.amt, 0.0));
|
|
||||||
region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(self.content_len);
|
|
||||||
painter.widget_within(&self.inner, region);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
|
|
||||||
ctx.width(&self.inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
|
|
||||||
ctx.height(&self.inner)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Scroll {
|
|
||||||
pub fn new(inner: StrongWidget, axis: Axis) -> Self {
|
|
||||||
Self {
|
|
||||||
inner,
|
|
||||||
axis,
|
|
||||||
amt: 0.0,
|
|
||||||
snap_end: true,
|
|
||||||
container_len: 0.0,
|
|
||||||
content_len: 0.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_amt(&mut self) {
|
|
||||||
self.amt = self.amt.max(0.0);
|
|
||||||
let len = (self.content_len - self.container_len).max(0.0);
|
|
||||||
self.amt = self.amt.min(len);
|
|
||||||
self.snap_end = self.amt == len;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn scroll(&mut self, amt: f32) {
|
|
||||||
self.amt -= amt;
|
|
||||||
self.update_amt();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
//! `ScrollArea`: a fixed child, slid about by a [`ScrollController`].
|
||||||
|
//!
|
||||||
|
//! **`docs/SCROLL.md` is the overview** -- the one sign convention, what
|
||||||
|
//! `amt` means, and how this differs from a `LazySpan`, which scrolls
|
||||||
|
//! itself. Read it first; this file is the detail.
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// A scrolling view over a child that is a fixed lump: it is measured
|
||||||
|
/// whole and then moved, which is what makes a scroll tick an O(1) move of
|
||||||
|
/// one subtree rather than a redraw.
|
||||||
|
///
|
||||||
|
/// **"Area" because it only scrolls a predefined one** (Iris, 2026-09-08):
|
||||||
|
/// a child that lays out lazily cannot be measured whole or moved as a
|
||||||
|
/// lump, and virtualising it inside one of these would never update which
|
||||||
|
/// rows it shows, since a scroll tick offers a same-size moved region and
|
||||||
|
/// `draw_inner` never re-enters the child. That case is `LazySpan`, which
|
||||||
|
/// owns a controller of its own instead of being wrapped in one of these.
|
||||||
|
pub struct ScrollArea {
|
||||||
|
inner: StrongWidget,
|
||||||
|
/// The position, the gesture, the fling and the pin -- everything
|
||||||
|
/// about scrolling that is not this widget's own layout, shared with
|
||||||
|
/// `LazySpan` rather than reimplemented beside it.
|
||||||
|
ctl: ScrollController,
|
||||||
|
container_len: f32,
|
||||||
|
/// How long the content is along the axis, as of the last draw --
|
||||||
|
/// `None` until this widget has drawn once.
|
||||||
|
///
|
||||||
|
/// An `Option` rather than a `0.0` that stands in for both, because
|
||||||
|
/// the two answers led somewhere different and the code could not tell
|
||||||
|
/// them apart: on the first frame the clamp computed a scroll range of
|
||||||
|
/// zero, concluded from `amt == range` that the area was sitting at
|
||||||
|
/// its end, and pinned it -- so the next frame, now knowing the real
|
||||||
|
/// length, jumped to it. A code fence therefore opened at the end of
|
||||||
|
/// its longest line, mid-word (`iris/run-headless.sh phone`,
|
||||||
|
/// 2026-09-08).
|
||||||
|
content_len: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scrollable for ScrollArea {
|
||||||
|
fn controller(&self) -> &ScrollController {
|
||||||
|
&self.ctl
|
||||||
|
}
|
||||||
|
|
||||||
|
fn controller_mut(&mut self) -> &mut ScrollController {
|
||||||
|
&mut self.ctl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for ScrollArea {
|
||||||
|
/// A scroll area animates exactly one thing, its fling. The
|
||||||
|
/// registration that makes this run is `UiData::animate`, which
|
||||||
|
/// `WidgetLike::scrollable`'s own drag handler calls the frame a
|
||||||
|
/// release starts one.
|
||||||
|
fn tick(&mut self, now: Instant) -> bool {
|
||||||
|
self.tick_fling(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Measure, then place -- the same idiom `LazySpan` uses, for the same
|
||||||
|
/// reason: nothing drawn may depend on a length measured last frame.
|
||||||
|
///
|
||||||
|
/// **The child is drawn twice, and only the second decides anything.**
|
||||||
|
/// The first is handed last frame's length as a *hint*, and it exists
|
||||||
|
/// only so that the usual case, where the content's length did not
|
||||||
|
/// change, offers the same region twice: `draw_inner` then makes the
|
||||||
|
/// first call an O(1) `mov` and returns at the first line of the
|
||||||
|
/// second. A frame on which the content did grow or shrink pays one
|
||||||
|
/// real extra draw, and that is a frame on which the content was being
|
||||||
|
/// redrawn anyway.
|
||||||
|
///
|
||||||
|
/// The alternative -- place against the hint and let the next frame
|
||||||
|
/// fix it -- is what Iris found on her phone (2026-09-08): every
|
||||||
|
/// newline typed into the composer drew the field in a box one line
|
||||||
|
/// short of its text, and since that text is centred in its box it
|
||||||
|
/// hung half a line past each end. There was no next frame: nothing
|
||||||
|
/// dirtied that subtree again, so the stale placement was the last one
|
||||||
|
/// drawn, until the keyboard closed and its inset rewrite forced a
|
||||||
|
/// redraw ("it fixes itself"). **Layout is a pure function of the
|
||||||
|
/// state, not of how many frames have been drawn** (Iris, 2026-09-08)
|
||||||
|
/// -- a correction that needs a second frame is a frame drawn wrong.
|
||||||
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||||
|
// Every length here is resolved against the box this widget was
|
||||||
|
// **offered** (`px_size`), never `output_size`: a scroll area is
|
||||||
|
// routinely smaller than the window -- the composer's field is
|
||||||
|
// capped at six lines by a `MaxSize` around it -- and measuring
|
||||||
|
// the window instead would make the pan range, and so where the
|
||||||
|
// content sits, a function of the screen rather than of the box.
|
||||||
|
let axis = self.ctl.axis();
|
||||||
|
let container_len = painter.px_size().axis(axis);
|
||||||
|
self.container_len = container_len;
|
||||||
|
// Learned from the frame rather than passed in: a fling's
|
||||||
|
// deceleration is a physical quantity and needs the real display
|
||||||
|
// density, and `draw` is where this widget meets the only thing
|
||||||
|
// that knows it.
|
||||||
|
self.ctl.set_density(painter.density());
|
||||||
|
|
||||||
|
// Where the delta asked for since the last frame puts the content.
|
||||||
|
// Already inside the range the previous frame published, so it is
|
||||||
|
// the position to *measure* against; the clamp below is what the
|
||||||
|
// length just measured has to say about it.
|
||||||
|
let delta = self.ctl.take_delta();
|
||||||
|
let travelled = self.ctl.amt() - delta;
|
||||||
|
self.ctl.set_amt(travelled);
|
||||||
|
|
||||||
|
// The container's own length stands in as the hint until anything
|
||||||
|
// has been measured: a zero-length region on the first frame would
|
||||||
|
// place the child's primitives against a box of no size.
|
||||||
|
let hint = self.content_len.unwrap_or(container_len);
|
||||||
|
let used = painter.widget_within(&self.inner, self.child_region(hint));
|
||||||
|
|
||||||
|
// A child reporting `rel` means "this fraction of what I was
|
||||||
|
// offered", and what it was offered is this scroll area -- so the
|
||||||
|
// container, again, is what that resolves against.
|
||||||
|
let measured = used
|
||||||
|
.axis(axis)
|
||||||
|
.apply_rest(painter.density())
|
||||||
|
.to_abs(container_len);
|
||||||
|
self.content_len = Some(measured);
|
||||||
|
let range = (measured - container_len).max(0.0);
|
||||||
|
|
||||||
|
// The end-pin, and then the clamp, against the length just
|
||||||
|
// measured. Deliberately not also run before the measuring draw
|
||||||
|
// above -- clamping against the hint would let a stale length
|
||||||
|
// reduce `amt` in a way this pass cannot undo, and then where the
|
||||||
|
// content sits would depend on the previous frame after all.
|
||||||
|
//
|
||||||
|
// Only a frame with no delta of its own re-pins: the pin means
|
||||||
|
// "stay flush with the end as the content grows", and a reader who
|
||||||
|
// just scrolled away from that end has said otherwise. (A delta
|
||||||
|
// cannot be moving *toward* the end here -- the travel published
|
||||||
|
// below is zero that way while pinned, so `take_delta` has already
|
||||||
|
// clipped it.)
|
||||||
|
let amt = if self.ctl.pinned_to_end() && delta == 0.0 {
|
||||||
|
range
|
||||||
|
} else {
|
||||||
|
travelled.clamp(0.0, range)
|
||||||
|
};
|
||||||
|
self.ctl.set_amt(amt);
|
||||||
|
self.ctl.set_pinned_to_end(amt >= range);
|
||||||
|
self.ctl.set_travel(Travel {
|
||||||
|
back: amt,
|
||||||
|
fwd: range - amt,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The **content's** size, not the container's. A parent that can
|
||||||
|
// grow (the composer's bar) should hug the text until its own cap
|
||||||
|
// stops it, and reporting the container instead would make this
|
||||||
|
// widget's answer a function of the answer -- the bar is sized
|
||||||
|
// from what is reported here, so it collapses to nothing and never
|
||||||
|
// recovers. What keeps the content inside the offered box is the
|
||||||
|
// mask a caller puts around it (`.scrollable(..).masked()`), not
|
||||||
|
// this number.
|
||||||
|
painter.widget_within(&self.inner, self.child_region(measured))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScrollArea {
|
||||||
|
/// `pin` says which end this area opens at and clings to -- see
|
||||||
|
/// [`Pin`], and `WidgetLike::scrollable`, which is how one of these is
|
||||||
|
/// normally built.
|
||||||
|
pub fn new(inner: StrongWidget, axis: Axis, pin: Pin) -> Self {
|
||||||
|
Self {
|
||||||
|
inner,
|
||||||
|
// A fixed child is laid out from the box's negative edge
|
||||||
|
// onward, always, so the end of its content is the positive
|
||||||
|
// one -- which is what makes `Pin::End` and `Pin::Pos` the
|
||||||
|
// same pin here and different ones in a reversed `LazySpan`.
|
||||||
|
ctl: ScrollController::new(Dir::new(axis, Sign::Pos), pin),
|
||||||
|
container_len: 0.0,
|
||||||
|
content_len: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the child sits for a given content length: a box that long
|
||||||
|
/// along the scroll axis, pulled back by `amt`. The length is taken as
|
||||||
|
/// a parameter rather than read from `content_len`, because `draw`
|
||||||
|
/// places twice -- once against last frame's length and once against
|
||||||
|
/// the one it has just measured -- and the two must be the same
|
||||||
|
/// arithmetic.
|
||||||
|
fn child_region(&self, content_len: f32) -> UiRegion {
|
||||||
|
let axis = self.ctl.axis();
|
||||||
|
let mut region = UiRegion::FULL;
|
||||||
|
region.axis_mut(axis).end = region.axis(axis).start.offset(content_len);
|
||||||
|
region.offset(Vec2::from_axis(axis, -self.ctl.amt(), 0.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::layout_tests::TestRsc;
|
||||||
|
use crate::sense::{CursorButton, DRAG_SLOP, PointerRequests};
|
||||||
|
use iris_core::UiData;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// A scroll area with 1000px of content in a 100px box, drawn once and
|
||||||
|
/// settled somewhere in the middle so a drag has room in both
|
||||||
|
/// directions.
|
||||||
|
///
|
||||||
|
/// Built and rendered for real rather than assembled field by field,
|
||||||
|
/// because a delta is spent in `draw` now (the controller banks it, and
|
||||||
|
/// only a layout knows where the content ends) -- so a test that never
|
||||||
|
/// draws would watch `amt` never move and read that as a broken
|
||||||
|
/// gesture.
|
||||||
|
fn area() -> (Fixture, WidgetId) {
|
||||||
|
area_on(Axis::Y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same fixture on either axis -- a code fence pans sideways
|
||||||
|
/// through one of these exactly as a field pans down, and the pair of
|
||||||
|
/// them is what caught a fling that only worked vertically.
|
||||||
|
fn area_on(axis: Axis) -> (Fixture, WidgetId) {
|
||||||
|
let mut rsc = TestRsc {
|
||||||
|
ui: UiData::default(),
|
||||||
|
};
|
||||||
|
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
|
||||||
|
let id = fill.id();
|
||||||
|
let long = Some(Len::abs(1000.0));
|
||||||
|
let tall = rsc.ui.widgets.add_strong(Sized {
|
||||||
|
inner: fill,
|
||||||
|
x: (axis == Axis::X).then_some(long).flatten(),
|
||||||
|
y: (axis == Axis::Y).then_some(long).flatten(),
|
||||||
|
});
|
||||||
|
let area = rsc
|
||||||
|
.ui
|
||||||
|
.widgets
|
||||||
|
.add_strong(ScrollArea::new(tall.any(), axis, Pin::Start));
|
||||||
|
let weak = area.weak();
|
||||||
|
let root = area.any();
|
||||||
|
let mut render = UiRenderState::new();
|
||||||
|
render.resize((100.0, 100.0));
|
||||||
|
render.update(&root, &mut rsc);
|
||||||
|
|
||||||
|
let mut fixture = Fixture {
|
||||||
|
rsc,
|
||||||
|
area: weak,
|
||||||
|
root,
|
||||||
|
render,
|
||||||
|
};
|
||||||
|
// 400px in, which is the middle of the 900px of travel this
|
||||||
|
// content has.
|
||||||
|
fixture.get().scroll(-400.0);
|
||||||
|
fixture.draw();
|
||||||
|
assert!((fixture.amt() - 400.0).abs() < 0.01);
|
||||||
|
(fixture, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The area under test with everything needed to draw it -- the drag
|
||||||
|
/// tests all do the same three things (reach the widget, draw, read
|
||||||
|
/// `amt`) and each of the three is a line of arena plumbing.
|
||||||
|
struct Fixture {
|
||||||
|
rsc: TestRsc,
|
||||||
|
area: WeakWidget<ScrollArea>,
|
||||||
|
root: StrongWidget,
|
||||||
|
render: UiRenderState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn get(&mut self) -> &mut ScrollArea {
|
||||||
|
self.rsc.ui.widgets.get_mut(&self.area).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(&mut self) {
|
||||||
|
self.render.update(&self.root, &mut self.rsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn amt(&self) -> f32 {
|
||||||
|
self.rsc.ui.widgets.get(&self.area).unwrap().amt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One frame of a fling, the way `UiData::tick_animations` drives
|
||||||
|
/// it: tick, then draw. Answers whether it is still going.
|
||||||
|
fn fling_frame(&mut self, now: Instant) -> bool {
|
||||||
|
let still = self.get().tick(now);
|
||||||
|
self.draw();
|
||||||
|
still
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One frame of a touch gesture, followed by the draw that spends it.
|
||||||
|
fn press(f: &mut Fixture, id: WidgetId, sense: CursorSense, y: f32, t: Instant) {
|
||||||
|
drag(f, id, sense, Vec2::new(0.0, y), t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same, for a gesture whose position is not on the Y axis.
|
||||||
|
fn drag(f: &mut Fixture, id: WidgetId, sense: CursorSense, pos: Vec2, t: Instant) {
|
||||||
|
let pointer = PointerRequests::default();
|
||||||
|
let flung = f.get().drag(&pointer, id, sense, pos, t);
|
||||||
|
// What `WidgetLike::scrollable`'s own handler does with the
|
||||||
|
// answer, and the half a fling does not move without.
|
||||||
|
if flung {
|
||||||
|
let id = f.area.id();
|
||||||
|
f.rsc.ui.animate(id);
|
||||||
|
}
|
||||||
|
f.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_vertical_finger_drag_pans_the_content_with_the_finger() {
|
||||||
|
let (mut f, id) = area();
|
||||||
|
let t = Instant::now();
|
||||||
|
press(
|
||||||
|
&mut f,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
0.0,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
// Finger down by well past the slop: the content follows it down,
|
||||||
|
// which for this widget means *less* `amt`.
|
||||||
|
press(
|
||||||
|
&mut f,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
DRAG_SLOP + 30.0,
|
||||||
|
t + Duration::from_millis(20),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(f.amt() - 370.0).abs() < 0.01,
|
||||||
|
"expected the 30px past the slop to be applied downward, got amt={}",
|
||||||
|
f.amt()
|
||||||
|
);
|
||||||
|
// ...and the next frame's motion is a plain per-frame delta.
|
||||||
|
press(
|
||||||
|
&mut f,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
DRAG_SLOP + 50.0,
|
||||||
|
t + Duration::from_millis(40),
|
||||||
|
);
|
||||||
|
assert!((f.amt() - 350.0).abs() < 0.01, "amt={}", f.amt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The half the change had no reason to touch: a press that never
|
||||||
|
/// leaves the slop is a tap, and must move nothing at all -- otherwise
|
||||||
|
/// every tap on a scrollable field nudges its text.
|
||||||
|
#[test]
|
||||||
|
fn a_press_that_stays_inside_the_slop_does_not_scroll() {
|
||||||
|
let (mut f, id) = area();
|
||||||
|
let t = Instant::now();
|
||||||
|
press(
|
||||||
|
&mut f,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
0.0,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
for (i, y) in [1.0, -2.0, DRAG_SLOP - 0.5].into_iter().enumerate() {
|
||||||
|
press(
|
||||||
|
&mut f,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
y,
|
||||||
|
t + Duration::from_millis(10 * (i as u64 + 1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
press(
|
||||||
|
&mut f,
|
||||||
|
id,
|
||||||
|
CursorSense::PressEnd(CursorButton::Left),
|
||||||
|
DRAG_SLOP - 0.5,
|
||||||
|
t + Duration::from_millis(50),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(f.amt() - 400.0).abs() < 0.01,
|
||||||
|
"a tap scrolled: amt={}",
|
||||||
|
f.amt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A horizontal drag is not this widget's gesture: it must stay put
|
||||||
|
/// rather than pick up the vertical noise in a sideways swipe.
|
||||||
|
#[test]
|
||||||
|
fn a_horizontal_drag_does_not_scroll() {
|
||||||
|
let (mut f, id) = area();
|
||||||
|
let t = Instant::now();
|
||||||
|
drag(
|
||||||
|
&mut f,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 0.0),
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
drag(
|
||||||
|
&mut f,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
Vec2::new(120.0, 3.0),
|
||||||
|
t + Duration::from_millis(20),
|
||||||
|
);
|
||||||
|
assert!((f.amt() - 400.0).abs() < 0.01, "amt={}", f.amt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panning stops at the ends of the content rather than running off,
|
||||||
|
/// which is `update_amt`'s clamp -- checked through `drag` so the two
|
||||||
|
/// cannot drift apart.
|
||||||
|
#[test]
|
||||||
|
fn a_pan_past_the_end_clamps_instead_of_running_off() {
|
||||||
|
let (mut f, id) = area();
|
||||||
|
let t = Instant::now();
|
||||||
|
drag(
|
||||||
|
&mut f,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 0.0),
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
drag(
|
||||||
|
&mut f,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 5000.0),
|
||||||
|
t + Duration::from_millis(20),
|
||||||
|
);
|
||||||
|
assert!((f.amt() - 0.0).abs() < 0.01, "amt={}", f.amt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iris, 2026-09-08: "Flinging doesn't work in horizontal scroll
|
||||||
|
/// areas. Flinging should be enabled by default in all scroll areas
|
||||||
|
/// on android to match composes behavior." A release with real
|
||||||
|
/// velocity coasts, decelerating, and settles on its own.
|
||||||
|
#[test]
|
||||||
|
fn a_released_pan_flings_and_settles() {
|
||||||
|
for axis in [Axis::X, Axis::Y] {
|
||||||
|
let (mut f, id) = area_on(axis);
|
||||||
|
let t = Instant::now();
|
||||||
|
let at = |d: f32| Vec2::from_axis(axis, d, 0.0);
|
||||||
|
|
||||||
|
drag(
|
||||||
|
&mut f,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
at(0.0),
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
// Four samples 8ms apart, accelerating away from the start --
|
||||||
|
// three is the fewest `VelocityTracker`'s quadratic fit can
|
||||||
|
// use, so this is a gesture that genuinely has a velocity.
|
||||||
|
for (i, d) in [-40.0, -100.0, -180.0, -280.0].into_iter().enumerate() {
|
||||||
|
drag(
|
||||||
|
&mut f,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
at(d),
|
||||||
|
t + Duration::from_millis(8 * (i as u64 + 1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let at_release = f.amt();
|
||||||
|
drag(
|
||||||
|
&mut f,
|
||||||
|
id,
|
||||||
|
CursorSense::PressEnd(CursorButton::Left),
|
||||||
|
at(-280.0),
|
||||||
|
t + Duration::from_millis(32),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
f.get().is_scrolling(),
|
||||||
|
"{axis:?}: a released pan with velocity must fling"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Frames at 8ms until it stops, with each step no longer than
|
||||||
|
// the one before it -- a coast that does not decelerate is
|
||||||
|
// the linear-spline bug this crate has had once already.
|
||||||
|
let mut last_step = f32::INFINITY;
|
||||||
|
let mut ticks = 0;
|
||||||
|
let mut now = t + Duration::from_millis(32);
|
||||||
|
while f.fling_frame(now) {
|
||||||
|
let before = f.amt();
|
||||||
|
now += Duration::from_millis(8);
|
||||||
|
f.fling_frame(now);
|
||||||
|
let step = (f.amt() - before).abs();
|
||||||
|
assert!(
|
||||||
|
step <= last_step + 0.01,
|
||||||
|
"{axis:?}: the fling sped up: {last_step} then {step}"
|
||||||
|
);
|
||||||
|
last_step = step;
|
||||||
|
ticks += 1;
|
||||||
|
assert!(ticks < 10_000, "{axis:?}: the fling never settled");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
f.amt() > at_release,
|
||||||
|
"{axis:?}: the fling moved the content the wrong way: {at_release} -> {}",
|
||||||
|
f.amt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wall: a fling must not spend its remaining distance on content
|
||||||
|
/// that is not there. Released hard toward the start, it settles
|
||||||
|
/// exactly on it.
|
||||||
|
#[test]
|
||||||
|
fn a_fling_stops_at_the_end_of_the_content() {
|
||||||
|
// Both walls. A positive delta is applied as `amt -= delta`, so a
|
||||||
|
// positive velocity runs toward the start of the content and a
|
||||||
|
// negative one toward its end; 1000px of content in a 100px box
|
||||||
|
// leaves `amt` in 0..=900.
|
||||||
|
for (velocity, wall) in [(50_000.0f32, 0.0f32), (-50_000.0, 900.0)] {
|
||||||
|
let (mut f, _id) = area();
|
||||||
|
f.get().fling(velocity);
|
||||||
|
let t = Instant::now();
|
||||||
|
let mut now = t;
|
||||||
|
for _ in 0..1_000 {
|
||||||
|
if !f.fling_frame(now) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
now += Duration::from_millis(8);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!f.get().is_scrolling(),
|
||||||
|
"the fling toward {wall} ran past the content"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(f.amt() - wall).abs() < 0.01,
|
||||||
|
"it should have settled on {wall}, got amt={}",
|
||||||
|
f.amt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A finger on coasting content stops it there, from the first
|
||||||
|
/// sample, with no `DRAG_SLOP` to wait out -- the catch
|
||||||
|
/// `DragArbiter::press_start` describes, which a scroll area needs
|
||||||
|
/// for the same reason a list does now that it can coast at all.
|
||||||
|
#[test]
|
||||||
|
fn a_press_on_a_coasting_area_catches_it() {
|
||||||
|
let (mut f, id) = area();
|
||||||
|
f.get().fling(-4_000.0);
|
||||||
|
let t = Instant::now();
|
||||||
|
f.fling_frame(t);
|
||||||
|
f.fling_frame(t + Duration::from_millis(8));
|
||||||
|
let caught_at = f.amt();
|
||||||
|
assert!(f.get().is_scrolling(), "the fixture must still be moving");
|
||||||
|
|
||||||
|
let down = t + Duration::from_millis(16);
|
||||||
|
drag(
|
||||||
|
&mut f,
|
||||||
|
id,
|
||||||
|
CursorSense::PressStart(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 0.0),
|
||||||
|
down,
|
||||||
|
);
|
||||||
|
assert!(!f.get().is_scrolling(), "a touch-down must end the fling");
|
||||||
|
assert!(
|
||||||
|
(f.amt() - caught_at).abs() < 0.01,
|
||||||
|
"the down itself must not move the content, only stop it"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A move well under `DRAG_SLOP` still tracks the finger, because
|
||||||
|
// this press caught something that was moving.
|
||||||
|
drag(
|
||||||
|
&mut f,
|
||||||
|
id,
|
||||||
|
CursorSense::Pressing(CursorButton::Left),
|
||||||
|
Vec2::new(0.0, 2.0),
|
||||||
|
down + Duration::from_millis(8),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(f.amt() - (caught_at - 2.0)).abs() < 0.01,
|
||||||
|
"a caught press must pan from its first sample: {} -> {}",
|
||||||
|
caught_at,
|
||||||
|
f.amt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
//! The scrolling capability: one `ScrollController` holding everything a
|
||||||
|
//! scroll position is made of, and a `Scrollable` trait for the widgets
|
||||||
|
//! that own one.
|
||||||
|
//!
|
||||||
|
//! **`docs/SCROLL.md` is the overview** -- the one sign convention, what
|
||||||
|
//! `amt` means, and which widgets scroll. Read it first; this file is the
|
||||||
|
//! detail.
|
||||||
|
//!
|
||||||
|
//! Two widgets scroll in iris and they scroll differently: a
|
||||||
|
//! [`ScrollArea`](super::ScrollArea) slides a fixed child about as a lump,
|
||||||
|
//! and a [`LazySpan`](super::LazySpan) lays its own rows out from an
|
||||||
|
//! anchor and cannot be slid at all. What they share is everything that is
|
||||||
|
//! *not* the layout -- the gesture, the fling, the pin, the position and
|
||||||
|
//! the account of how far it can still go -- so that lives here, in a
|
||||||
|
//! plain struct each of them contains, rather than in a protocol between
|
||||||
|
//! them (Iris, 2026-09-08: "what about adding a scroll controller that
|
||||||
|
//! both scroll and lazy span contain").
|
||||||
|
//!
|
||||||
|
//! The contract with the owner is two calls, both in its `draw`:
|
||||||
|
//!
|
||||||
|
//! 1. [`ScrollController::take_delta`] -- what a wheel, a drag or a fling
|
||||||
|
//! asked for since the last layout, already clamped to the travel the
|
||||||
|
//! owner last reported.
|
||||||
|
//! 2. [`ScrollController::set_travel`], plus whichever of
|
||||||
|
//! [`ScrollController::moved_by`] or [`ScrollController::set_amt`] fits
|
||||||
|
//! how that owner knows where it ended up -- movement for a layout with
|
||||||
|
//! no fixed origin, an absolute position for one that has.
|
||||||
|
//!
|
||||||
|
//! Everything between the two is the owner's own layout, and everything
|
||||||
|
//! outside them is the same for both.
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
use crate::sense::{DragGesture, Flinger, GestureOutcome, PointerRequests, PressState};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// Which end of its content a scroll area clings to as that content
|
||||||
|
/// grows, said either way round -- an enum rather than the `at_end: bool`
|
||||||
|
/// this used to be, because the flag sat at the end of two constructors
|
||||||
|
/// and `scrollable(axis, true)` says nothing at the call site about which
|
||||||
|
/// end `true` is.
|
||||||
|
///
|
||||||
|
/// **Two pairs, because there are two questions and they are not the same
|
||||||
|
/// one** (Iris, 2026-09-08: "that way you can select the pin based on the
|
||||||
|
/// axis's sign rather than the direction, so for example you can assure
|
||||||
|
/// it's always pinned to the bottom"):
|
||||||
|
///
|
||||||
|
/// - [`Pin::Start`] / [`Pin::End`] are **content-relative**: the first row
|
||||||
|
/// or the newest one, wherever the layout happens to put it. A
|
||||||
|
/// transcript wants `End` -- the newest message -- and does not care
|
||||||
|
/// which edge of the screen that is.
|
||||||
|
/// - [`Pin::Neg`] / [`Pin::Pos`] are **axis-absolute**: the top or left
|
||||||
|
/// edge, and the bottom or right one, whichever end of the content sits
|
||||||
|
/// there. What to reach for when the *screen* position is the
|
||||||
|
/// requirement.
|
||||||
|
///
|
||||||
|
/// The two coincide for content laid out along the positive axis, which is
|
||||||
|
/// everything except a reversed `LazySpan` (`Dir::UP`, `Dir::LEFT`) --
|
||||||
|
/// where they are exact opposites, which is the whole reason both exist.
|
||||||
|
#[derive(Clone, Copy, Eq, PartialEq, Debug)]
|
||||||
|
pub enum Pin {
|
||||||
|
/// The start of the content: item 0, wherever it is drawn.
|
||||||
|
Start,
|
||||||
|
/// The end of the content: the newest item, wherever it is drawn.
|
||||||
|
End,
|
||||||
|
/// The top or left edge of the box, whichever end of the content is
|
||||||
|
/// there.
|
||||||
|
Neg,
|
||||||
|
/// The bottom or right edge of the box, whichever end of the content
|
||||||
|
/// is there.
|
||||||
|
Pos,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pin {
|
||||||
|
/// Resolve to the one question a scrollable actually acts on: does
|
||||||
|
/// content appended to the end bring the view with it? `dir` is the
|
||||||
|
/// way this owner's content runs, which is the only thing that tells
|
||||||
|
/// the axis-absolute pair from the content-relative one.
|
||||||
|
fn pinned_to_end(self, dir: Dir) -> bool {
|
||||||
|
match self {
|
||||||
|
Pin::Start => false,
|
||||||
|
Pin::End => true,
|
||||||
|
Pin::Neg => dir.sign == Sign::Neg,
|
||||||
|
Pin::Pos => dir.sign == Sign::Pos,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far a scrollable can still travel from where it is, as of its last
|
||||||
|
/// layout, in the same screen-space units a delta is in.
|
||||||
|
///
|
||||||
|
/// `f32::INFINITY` where the end is not in sight: a lazy layout genuinely
|
||||||
|
/// does not know how much content lies past the rows it has walked, and
|
||||||
|
/// saying "infinity" is the honest answer that `clamp` also happens to
|
||||||
|
/// take with no branch. The wall is then found by the walk, which is why
|
||||||
|
/// the owner reports what it *did* as well as what it can do.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct Travel {
|
||||||
|
/// The bound on a **positive** delta -- scrolling up or left, back
|
||||||
|
/// toward the start of the content.
|
||||||
|
pub back: f32,
|
||||||
|
/// The bound on a **negative** delta -- scrolling down or right,
|
||||||
|
/// onward toward the end of the content. Positive itself: it is a
|
||||||
|
/// distance, and the sign it bounds is the caller's.
|
||||||
|
pub fwd: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Travel {
|
||||||
|
/// Nothing known yet, so nothing is bounded -- what a scrollable
|
||||||
|
/// starts with and what it reports for an axis whose content it has
|
||||||
|
/// not measured.
|
||||||
|
pub const UNBOUNDED: Self = Self {
|
||||||
|
back: f32::INFINITY,
|
||||||
|
fwd: f32::INFINITY,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The bound on a delta of this sign, as a positive distance.
|
||||||
|
fn toward(&self, delta: f32) -> f32 {
|
||||||
|
if delta >= 0.0 { self.back } else { self.fwd }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The state a scroll position is made of, owned by the widget that
|
||||||
|
/// scrolls: where it is, what it was asked to do next, how far it can go,
|
||||||
|
/// which end it clings to, and the gesture and fling that drive it.
|
||||||
|
///
|
||||||
|
/// See the module doc for the two-call contract with its owner, and
|
||||||
|
/// [`Scrollable`] for the trait that reaches one.
|
||||||
|
pub struct ScrollController {
|
||||||
|
/// Which way this area's content runs: the axis it pans along, and the
|
||||||
|
/// sign the content grows in. A plain `ScrollArea` always grows the
|
||||||
|
/// positive way; a `LazySpan` passes its own `dir`, which is what
|
||||||
|
/// tells [`Pin::Pos`]/[`Pin::Neg`] from [`Pin::Start`]/[`Pin::End`].
|
||||||
|
dir: Dir,
|
||||||
|
/// Where this area has got to, counting **forward through the
|
||||||
|
/// content**: 0 at the start, growing as the reader moves on. The
|
||||||
|
/// opposite sign to a delta, which counts the way the finger moves.
|
||||||
|
///
|
||||||
|
/// For a `ScrollArea` it is a position, clamped into the content's
|
||||||
|
/// real length. For a `LazySpan` it is **movement, not position** --
|
||||||
|
/// paging rows in above moves the origin and the span cannot say by
|
||||||
|
/// how much, never having measured them -- so the direction is
|
||||||
|
/// comparable between the two and the absolute value is not.
|
||||||
|
amt: f32,
|
||||||
|
/// Asked for but not yet laid out: how far a wheel, a drag or a fling
|
||||||
|
/// has moved this area since the last draw. Taken and cleared by
|
||||||
|
/// [`Self::take_delta`], which is the only place it is spent, because
|
||||||
|
/// the owner's `draw` is the only place the walls are known.
|
||||||
|
pending: f32,
|
||||||
|
/// What the owner's last layout said was left, and what `take_delta`
|
||||||
|
/// clamps against.
|
||||||
|
travel: Travel,
|
||||||
|
/// Whether this area is currently flush against the end of its
|
||||||
|
/// content, so that content appended to it should bring the view
|
||||||
|
/// along. Set from [`Pin`] at construction and recomputed by the owner
|
||||||
|
/// at the end of every layout -- it is live state, not a preference: a
|
||||||
|
/// reader who scrolls away from the end stops being pinned to it, and
|
||||||
|
/// scrolling back re-pins.
|
||||||
|
pinned_to_end: bool,
|
||||||
|
/// Touch panning. Arbitration, `DRAG_SLOP` and pointer capture all
|
||||||
|
/// live in `sense.rs`; only what a committed pan *means* is decided
|
||||||
|
/// here. See [`Self::drag`].
|
||||||
|
gesture: DragGesture,
|
||||||
|
/// The momentum a release leaves behind. Every scroll area flings, on
|
||||||
|
/// either axis and with nothing to opt into -- Compose's `scrollable`
|
||||||
|
/// attaches `ScrollableDefaults.flingBehavior()` on every axis it is
|
||||||
|
/// given, and Iris asked for the same (2026-09-08: "flinging should be
|
||||||
|
/// enabled by default in all scroll areas on android to match composes
|
||||||
|
/// behavior").
|
||||||
|
fling: Flinger,
|
||||||
|
/// Physical pixels per dp, copied from the painter on every draw --
|
||||||
|
/// what a fling's deceleration is computed against. 1.0 until the
|
||||||
|
/// owner has drawn once, which is also the only state in which nothing
|
||||||
|
/// can be flung, since there is no content measured yet.
|
||||||
|
density: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScrollController {
|
||||||
|
pub fn new(dir: Dir, pin: Pin) -> Self {
|
||||||
|
Self {
|
||||||
|
dir,
|
||||||
|
amt: 0.0,
|
||||||
|
pending: 0.0,
|
||||||
|
travel: Travel::UNBOUNDED,
|
||||||
|
pinned_to_end: pin.pinned_to_end(dir),
|
||||||
|
gesture: DragGesture::on(dir.axis),
|
||||||
|
fling: Flinger::new(),
|
||||||
|
density: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which way this area pans.
|
||||||
|
pub fn axis(&self) -> Axis {
|
||||||
|
self.dir.axis
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which way this area's content runs -- the axis it pans along and
|
||||||
|
/// the sign it grows in. What resolves a [`Pin`].
|
||||||
|
pub fn dir(&self) -> Dir {
|
||||||
|
self.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far the content has been pulled past the container's leading
|
||||||
|
/// edge -- see the field for what that means for each kind of owner.
|
||||||
|
pub fn amt(&self) -> f32 {
|
||||||
|
self.amt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pan by `amt`, in the finger's direction: **positive scrolls up or
|
||||||
|
/// left**, moving the content the positive way along the axis. One
|
||||||
|
/// convention, everywhere, and a screen direction rather than a
|
||||||
|
/// logical one so that it means the same thing to a widget laid out
|
||||||
|
/// backwards (Iris, 2026-09-08).
|
||||||
|
///
|
||||||
|
/// Banked rather than applied: where this area can actually go is a
|
||||||
|
/// question only its owner's layout can answer, and the owner's `draw`
|
||||||
|
/// is where that answer exists.
|
||||||
|
pub fn scroll(&mut self, amt: f32) {
|
||||||
|
self.pending += amt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What has been asked for since the last layout, clamped to the
|
||||||
|
/// travel that layout reported. Called once at the top of the owner's
|
||||||
|
/// `draw`.
|
||||||
|
///
|
||||||
|
/// **Clipping it stops a fling**, because a fling that keeps spending
|
||||||
|
/// its distance on content that is not there is what left a hard flick
|
||||||
|
/// parked a whole screen past the first row of the bench fixture
|
||||||
|
/// (docs/IRIS_TODO.md, 2026-09-07). This catches the wall the owner
|
||||||
|
/// could already see; [`Self::set_travel`] catches the one it finds by
|
||||||
|
/// walking.
|
||||||
|
pub fn take_delta(&mut self) -> f32 {
|
||||||
|
let asked = std::mem::take(&mut self.pending);
|
||||||
|
let limit = self.travel.toward(asked);
|
||||||
|
let taken = asked.clamp(-limit, limit);
|
||||||
|
if taken != asked {
|
||||||
|
self.fling.stop();
|
||||||
|
}
|
||||||
|
taken
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record content this area really moved, and by how much, in a
|
||||||
|
/// delta's own sign. For an owner that cannot state an absolute
|
||||||
|
/// position -- a lazy layout, whose origin moves as rows are paged in
|
||||||
|
/// above it.
|
||||||
|
pub fn moved_by(&mut self, delta: f32) {
|
||||||
|
self.amt -= delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set where this area is outright, for an owner that knows: a
|
||||||
|
/// `ScrollArea` has measured its content and clamps against its real
|
||||||
|
/// length, and a jump to an end is a position rather than travel.
|
||||||
|
pub fn set_amt(&mut self, amt: f32) {
|
||||||
|
self.amt = amt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish how far this area can still go, from the layout that just
|
||||||
|
/// ran. Stops a fling with nothing left in the direction it is
|
||||||
|
/// travelling -- the wall a lazy layout only finds by walking to it,
|
||||||
|
/// reported in the same frame that found it.
|
||||||
|
pub fn set_travel(&mut self, travel: Travel) {
|
||||||
|
self.travel = travel;
|
||||||
|
if let Some(v) = self.fling.velocity()
|
||||||
|
&& travel.toward(v) <= 0.0
|
||||||
|
{
|
||||||
|
self.fling.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the last layout said was left. Read by an owner that has to
|
||||||
|
/// reconcile its own walls with what it was allowed to take.
|
||||||
|
pub fn travel(&self) -> Travel {
|
||||||
|
self.travel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this area is flush against the end of its content, so that
|
||||||
|
/// an appended row should bring the view with it. The owner recomputes
|
||||||
|
/// this at the end of every layout; a caller may set it to re-pin (a
|
||||||
|
/// "jump to latest" button) or to let go.
|
||||||
|
pub fn pinned_to_end(&self) -> bool {
|
||||||
|
self.pinned_to_end
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_pinned_to_end(&mut self, pinned: bool) {
|
||||||
|
self.pinned_to_end = pinned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Physical pixels per dp, which a fling's deceleration is computed
|
||||||
|
/// against. Learned from the frame rather than passed in: it is a
|
||||||
|
/// physical quantity, and the owner's `draw` is where it meets the
|
||||||
|
/// only thing that knows it.
|
||||||
|
pub fn set_density(&mut self, density: f32) {
|
||||||
|
self.density = density;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a fling at `velocity`, in [`Self::scroll`]'s direction
|
||||||
|
/// convention. Answers whether one actually started, which is the
|
||||||
|
/// caller's cue to register the widget for frames (`UiData::animate`).
|
||||||
|
/// Cancels any fling already in progress.
|
||||||
|
///
|
||||||
|
/// **Sets the fling; it does not drive it.** A fling moves only while
|
||||||
|
/// something calls [`Self::tick`] once per frame, and what does that
|
||||||
|
/// in a running app is `UiData::tick_animations`, over the ids
|
||||||
|
/// `UiData::animate` was given. Split that way because the two halves
|
||||||
|
/// have different owners: the velocity is this area's business and
|
||||||
|
/// whether anything animates at all is the frame loop's. Missing the
|
||||||
|
/// second call is what a finger fling did on Iris's phone for two
|
||||||
|
/// builds -- the velocity was right and nothing ever advanced it,
|
||||||
|
/// which looks exactly like a list that stops dead under the finger.
|
||||||
|
///
|
||||||
|
/// The density handed on is this area's own, taken from the painter,
|
||||||
|
/// not `1.0`: it does **not** cancel out of the spline, and a
|
||||||
|
/// hardcoded 1.0 against a 2.75-density screen made a flick that
|
||||||
|
/// should coast for about a second run for 45.
|
||||||
|
pub fn fling(&mut self, velocity: f32) -> bool {
|
||||||
|
self.fling.start(velocity, self.density)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel any fling in progress with no further movement -- the next
|
||||||
|
/// touch-down's job, since `AndroidFlingSpline`'s curve has no idea a
|
||||||
|
/// finger came back down and Android's own `Scroller` relies on the
|
||||||
|
/// view calling `abortAnimation` for the same reason.
|
||||||
|
pub fn cancel_fling(&mut self) {
|
||||||
|
self.fling.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a fling is coasting here right now. What a caller polls to
|
||||||
|
/// know whether this area is moving on its own (a test, and
|
||||||
|
/// [`PressState::scrolling`]'s own condition).
|
||||||
|
pub fn is_scrolling(&self) -> bool {
|
||||||
|
self.fling.is_flinging()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The velocity a fling in progress is coasting at, `None` when
|
||||||
|
/// nothing is flinging -- what a release's decision looks like from
|
||||||
|
/// the outside, so a test can read what the gesture measured rather
|
||||||
|
/// than re-timing the gesture itself.
|
||||||
|
pub fn fling_velocity(&self) -> Option<f32> {
|
||||||
|
self.fling.velocity()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance a fling by one frame, banking the distance it covered.
|
||||||
|
/// Answers whether it is still going, which is what
|
||||||
|
/// `UiData::tick_animations` reads to decide whether to keep the
|
||||||
|
/// widget registered -- so an owner's `Widget::tick` is this one line.
|
||||||
|
///
|
||||||
|
/// Stopping at a wall is [`Self::take_delta`]'s and
|
||||||
|
/// [`Self::set_travel`]'s, not this method's: both know where the
|
||||||
|
/// content ends and this one does not.
|
||||||
|
pub fn tick(&mut self, now: Instant) -> bool {
|
||||||
|
let delta = self.fling.tick(now);
|
||||||
|
self.scroll(delta);
|
||||||
|
self.fling.is_flinging()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one frame of a touch gesture over this area through.
|
||||||
|
/// Registered by `WidgetLike::scrollable`; a caller with an arbiter of
|
||||||
|
/// its own drives `DragGesture` itself and hands the committed pans
|
||||||
|
/// here instead (`transcript_ui::Selection`).
|
||||||
|
///
|
||||||
|
/// `id` is the owning widget's id, which `DragGesture` takes pointer
|
||||||
|
/// capture on once the gesture commits -- so the rest of the drag
|
||||||
|
/// reaches here even after the finger has left the area, and, just as
|
||||||
|
/// importantly, stops reaching whatever is *inside* it. That is what
|
||||||
|
/// resolves a vertical drag over a focused text field: the field sees
|
||||||
|
/// the first few frames, `iris::attr`'s `on_press` gives up its
|
||||||
|
/// pending selection the moment they pass `DRAG_SLOP` vertically, and
|
||||||
|
/// this takes the gesture over. Android's own `EditText` behaves the
|
||||||
|
/// same way -- a vertical drag scrolls, and only a long press selects.
|
||||||
|
///
|
||||||
|
/// Answers whether this frame *started a fling*, which is the caller's
|
||||||
|
/// cue to register the widget for frames (`UiData::animate`) -- see
|
||||||
|
/// [`Self::fling`].
|
||||||
|
pub fn drag(
|
||||||
|
&mut self,
|
||||||
|
pointer: &PointerRequests,
|
||||||
|
id: WidgetId,
|
||||||
|
sense: CursorSense,
|
||||||
|
pos_window: Vec2,
|
||||||
|
now: Instant,
|
||||||
|
) -> bool {
|
||||||
|
// A scroll area has no selection of its own to extend, so a drag
|
||||||
|
// across the axis stays `Undecided` and one along it past the slop
|
||||||
|
// pans, which is the whole contract here.
|
||||||
|
//
|
||||||
|
// `scrolling` is the other half: a finger put down on content that
|
||||||
|
// is still coasting means "stop it here", and commits to a pan on
|
||||||
|
// that very sample with no slop to wait out
|
||||||
|
// (`DragArbiter::press_start`). The fling is cancelled in the same
|
||||||
|
// breath, since the curve has no idea a finger came back down.
|
||||||
|
let mut press = PressState::default();
|
||||||
|
if self.gesture.starts_press(sense) {
|
||||||
|
press.scrolling = self.fling.is_flinging();
|
||||||
|
self.fling.stop();
|
||||||
|
}
|
||||||
|
match self
|
||||||
|
.gesture
|
||||||
|
.handle(pointer, id, sense, pos_window, now, press)
|
||||||
|
{
|
||||||
|
// The content follows the finger, and the same `dy` an
|
||||||
|
// arbiter of the caller's own (`Selection::drag`) hands
|
||||||
|
// straight to `scroll`.
|
||||||
|
GestureOutcome::Pan(dy) => self.scroll(dy),
|
||||||
|
// Same sign as `Pan`, since `tick` applies it through the same
|
||||||
|
// `scroll`.
|
||||||
|
GestureOutcome::Released(Some(v)) => return self.fling(v),
|
||||||
|
GestureOutcome::Undecided
|
||||||
|
| GestureOutcome::Tapped
|
||||||
|
| GestureOutcome::SelectStart
|
||||||
|
| GestureOutcome::SelectExtend
|
||||||
|
| GestureOutcome::Cancelled
|
||||||
|
| GestureOutcome::Released(None) => {}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A widget that scrolls its own content. Implementors hand back the
|
||||||
|
/// [`ScrollController`] they own and get everything a caller does with a
|
||||||
|
/// scroll position for free.
|
||||||
|
///
|
||||||
|
/// The two implementors are [`ScrollArea`](super::ScrollArea) and
|
||||||
|
/// [`LazySpan`](super::LazySpan). What distinguishes them is only *how*
|
||||||
|
/// they spend a delta, which is their `draw`'s business -- so a caller
|
||||||
|
/// that pans, flings, reads `amt` or re-pins works through this trait and
|
||||||
|
/// never has to know which it is holding.
|
||||||
|
pub trait Scrollable {
|
||||||
|
fn controller(&self) -> &ScrollController;
|
||||||
|
fn controller_mut(&mut self) -> &mut ScrollController;
|
||||||
|
|
||||||
|
/// Pan by `amt` -- positive scrolls up or left. See
|
||||||
|
/// [`ScrollController::scroll`].
|
||||||
|
fn scroll(&mut self, amt: f32) {
|
||||||
|
self.controller_mut().scroll(amt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See [`ScrollController::fling`], including why starting one is not
|
||||||
|
/// the same as driving it.
|
||||||
|
fn fling(&mut self, velocity: f32) -> bool {
|
||||||
|
self.controller_mut().fling(velocity)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_fling(&mut self) {
|
||||||
|
self.controller_mut().cancel_fling();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See [`ScrollController::drag`].
|
||||||
|
fn drag(
|
||||||
|
&mut self,
|
||||||
|
pointer: &PointerRequests,
|
||||||
|
id: WidgetId,
|
||||||
|
sense: CursorSense,
|
||||||
|
pos_window: Vec2,
|
||||||
|
now: Instant,
|
||||||
|
) -> bool {
|
||||||
|
self.controller_mut()
|
||||||
|
.drag(pointer, id, sense, pos_window, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See [`ScrollController::amt`] for what this counts, which differs
|
||||||
|
/// between the two implementors in origin though not in direction.
|
||||||
|
fn amt(&self) -> f32 {
|
||||||
|
self.controller().amt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn axis(&self) -> Axis {
|
||||||
|
self.controller().axis()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_scrolling(&self) -> bool {
|
||||||
|
self.controller().is_scrolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fling_velocity(&self) -> Option<f32> {
|
||||||
|
self.controller().fling_velocity()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pinned_to_end(&self) -> bool {
|
||||||
|
self.controller().pinned_to_end()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_pinned_to_end(&mut self, pinned: bool) {
|
||||||
|
self.controller_mut().set_pinned_to_end(pinned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance a fling by one frame -- an implementor's `Widget::tick` is
|
||||||
|
/// this, and nothing else animates in a scroll area.
|
||||||
|
fn tick_fling(&mut self, now: Instant) -> bool {
|
||||||
|
self.controller_mut().tick(now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register the two inputs of a scroll -- the wheel and a finger drag --
|
||||||
|
/// on a widget that owns a [`ScrollController`], and hand back the id.
|
||||||
|
///
|
||||||
|
/// The one place either is wired, shared by `WidgetLike::scrollable` and
|
||||||
|
/// `LazySpan::scrollable`: what differs between those two is only whether
|
||||||
|
/// there is a `ScrollArea` in the way, and a drag registered twice is a
|
||||||
|
/// gesture arbitrated twice.
|
||||||
|
pub fn scroll_senses<Rsc, Tag, W, WL>(w: WL, axis: Axis) -> impl WidgetIdFn<Rsc, W>
|
||||||
|
where
|
||||||
|
Rsc: HasEvents,
|
||||||
|
W: Widget + Scrollable,
|
||||||
|
WL: WidgetLike<Rsc, Tag, Widget = W>,
|
||||||
|
{
|
||||||
|
w.on(CursorSense::Scroll, move |ctx, rsc| {
|
||||||
|
let delta = ctx.data.scroll_delta.axis(axis) * 50.0;
|
||||||
|
ctx.widget(rsc).scroll(delta);
|
||||||
|
})
|
||||||
|
.on(CursorSense::drag_senses(), |ctx, rsc: &mut Rsc| {
|
||||||
|
let id = ctx.widget.id();
|
||||||
|
let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos);
|
||||||
|
let flung = ctx
|
||||||
|
.widget(rsc)
|
||||||
|
.drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time);
|
||||||
|
// The half that actually makes it move -- a fling is set by the
|
||||||
|
// widget and driven by the frame loop, and only this side can
|
||||||
|
// reach the loop. Only when one actually started: registering a
|
||||||
|
// widget that is not animating asks the next frame to find that
|
||||||
|
// out.
|
||||||
|
if flung {
|
||||||
|
rsc.ui_mut().animate(id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user