Compare commits
9
Commits
560a74caf8
...
c589a75fa0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c589a75fa0 | ||
|
|
4b62cc642e | ||
|
|
80c2eadec9 | ||
|
|
0b587629e6 | ||
|
|
3163256d2c | ||
|
|
6102e0d4d9 | ||
|
|
f0da383e28 | ||
|
|
2d3695a1d3 | ||
|
|
f06ee259b4 |
No files matched your search
@@ -8,6 +8,37 @@ capability that moved. Small and trivial changes do not go here.
|
||||
An entry gives the date, what changed, why, and a short before/after where
|
||||
it helps judge the change without the session that made it. Newest first.
|
||||
|
||||
## 2026-09-06: `List::fling`, `VelocityTracker`, `FlingCalculator` (IRIS_TODO.md's "swiping has no momentum")
|
||||
|
||||
`iris::widget::List` gained a real fling: `fling(velocity_px_per_s)` starts
|
||||
one (cancelled by the next touch-down via `cancel_fling`, or automatically
|
||||
once it settles or reaches loaded content's start/end), `is_scrolling()`
|
||||
reports whether one is running, and `tick_fling(now: Instant) -> bool`
|
||||
advances it and returns whether it is still going -- a caller that owns a
|
||||
`RequestRedraw` handle can hand it to the list once via the new
|
||||
`set_redraw_handle`, after which `List` re-arms its own next frame while
|
||||
flinging with no further polling needed; a caller driving a scripted
|
||||
benchmark instead calls `tick_fling` itself in a loop, same as it already
|
||||
drives `scroll`.
|
||||
|
||||
The physics is `iris::sense::FlingCalculator` + `VelocityTracker`
|
||||
(`sense.rs`, beside `DragArbiter`): a port of AOSP `SplineOverScroller`'s
|
||||
deceleration curve (the same one Compose's own `ScrollableDefaults.
|
||||
flingBehavior()` uses), cited at the definition, so a fling here travels
|
||||
the same distance a Compose `LazyColumn` would for the same initial
|
||||
velocity. `VelocityTracker` estimates that velocity from the drag's last
|
||||
~100ms of samples rather than one frame's last delta. Unit-tested:
|
||||
velocity from known samples, fling distance/duration against the closed-
|
||||
form spline result (within 1%), cancel-on-touch, and the start/end clamp
|
||||
(a fling stops rather than scrolling into content that was never loaded).
|
||||
|
||||
Before: a touch-drag panned exactly as far as the finger moved and stopped
|
||||
dead on release. After: releasing mid-drag continues scrolling and
|
||||
decelerates, matching the muscle memory every other Android scroll view
|
||||
already trained. `transcript_ui::selection::Selection::drag` wires this in
|
||||
-- a release only flings if the gesture had committed to panning
|
||||
(`DragArbiter::is_panning`, new), never a selection or an undecided tap.
|
||||
|
||||
## 2026-09-06: `UiRenderNode::new` returns `Result`, not `Self` (RUST.md's P0 box, phone-crash fix)
|
||||
|
||||
`iris_core::UiRenderNode::new(device, queue, config)` now returns
|
||||
@@ -552,3 +583,46 @@ inset bugs the emulator never showed).
|
||||
Explicit `Arc`-backed value passed to the callback and kept on
|
||||
`AndroidRenderer`, not a global — a caller wanting one on desktop builds
|
||||
its own the same way.
|
||||
|
||||
## 2026-09-06: `Len::dp`, physical pixels throughout, the keyboard glyph wipe
|
||||
|
||||
Iris's phone report on build a9232ac (screenshots): text now the right
|
||||
size but blurry; the keyboard still wipes every glyph; the header buttons
|
||||
have nothing behind them. All three are fixed; this entry is the public
|
||||
API side. docs/LAYOUT.md has the layout-side writeup, docs/RUST.md's P0
|
||||
box has the full investigation and the phone verification still to do.
|
||||
|
||||
- **The keyboard wipe was `surface_changed` rebuilding the whole renderer
|
||||
on every resize**, including an IME-driven one — a fresh, empty glyph
|
||||
atlas while the CPU-side glyph cache kept UV coordinates from the old
|
||||
one. `surface_changed` now calls `AndroidRenderer::resize` (reconfigures
|
||||
the surface and window uniform only) when a renderer is already live,
|
||||
and only builds a new one when there genuinely isn't one yet.
|
||||
- **`Len` has a third field, `dp`** (Android's dp / CSS's reference pixel,
|
||||
1/160in), beside the existing `abs` (now explicitly *physical* pixels)
|
||||
and `rel`/`rest`. `len_fns::dp`/`Len::dp` construct one, used exactly
|
||||
like `abs`/`rel`/`rest` — `dp(16)` instead of a bare `16` wherever a
|
||||
size should look the same physical size on any density. This is the
|
||||
unit IRIS_TODO.md's "density-independent length unit" item asked for;
|
||||
it replaces the previous stopgap (the whole rendered scene divided by
|
||||
`content_scale` then implicitly stretched back up), which is also what
|
||||
made text blurry — a glyph rasterised at the small, pre-stretch size and
|
||||
then upscaled onto the real framebuffer.
|
||||
- **`UiRenderState`/`Painter` gained `density()`/`set_density()`** (physical
|
||||
pixels per dp). Every place a length resolves (`Len::apply_rest`,
|
||||
`Size::to_uivec2`) now takes it; `Span::gap` and `Padding`'s four sides
|
||||
moved from a bare `f32` to `Len` so they take `dp(...)` too. A bare
|
||||
number anywhere is unaffected — still `abs`, physical pixels.
|
||||
- **Text is rasterised at physical resolution now.** `TextBuffer::shape`
|
||||
takes `density` and multiplies `font_size`/`line_height` (and any span
|
||||
override) by it before handing them to parley, so the atlas holds a
|
||||
bitmap at the size it is actually shown at rather than a low-resolution
|
||||
one stretched afterward.
|
||||
- **Everything at the Android boundary is physical pixels now** — window
|
||||
size, touch coordinates, insets (`LogicalInsets` renamed
|
||||
`WindowInsets`). The previous "logical" division by `content_scale` is
|
||||
gone; `content_scale` now feeds `set_density` instead.
|
||||
- Not yet verified on Iris's actual phone (this pass had no device) —
|
||||
built and checked on this checkout's emulator only. RUST.md's P0 box
|
||||
says what she should check for: crisp text at two densities, the
|
||||
keyboard no longer wiping, and the header's background.
|
||||
+38
-16
@@ -124,20 +124,31 @@ follow-ups. Recorded here rather than fixed in that pass, so a follow-up
|
||||
agent takes them without colliding with that pass's `bench_client.rs`/
|
||||
`android/view.rs`/`android/sense.rs` changes.
|
||||
|
||||
- [ ] **Swiping has no momentum.** iris's `List` pans exactly as far as the
|
||||
finger moves and stops dead on release -- unlike Compose, which flings
|
||||
and decelerates. Needs velocity tracking over the last several move
|
||||
samples and an Android-style decelerating fling, cancelled by the next
|
||||
touch-down, redrawing every frame until it settles. `BenchRun.kt`'s own
|
||||
fling phase (RUST.md's "Benchmark v2" box) is the reference shape:
|
||||
"travel way faster... which is better for stress testing."
|
||||
- [ ] **Scrolling down sometimes jitters the text.** Two named suspects,
|
||||
neither confirmed: `DragArbiter`'s slop being released as one jump
|
||||
(`iris/src/sense.rs`), or a per-frame pan delta applied a frame late.
|
||||
Measure by tracing the list's scroll offset per frame against a
|
||||
monotonic synthetic drag, the same way `iris/android-app/trace-draw.sh`-
|
||||
style instrumentation traced the touch-scroll dropout in RUST.md's I5
|
||||
box -- not by eyeballing a screenshot.
|
||||
- [x] **Swiping has no momentum, fixed 2026-09-06.** `List::fling`/
|
||||
`VelocityTracker`/`FlingCalculator` (`iris/src/widget/list.rs`,
|
||||
`iris/src/sense.rs`) -- IRIS.md's 2026-09-06 entry has the full account.
|
||||
Wired through `Selection::drag`'s release path, cancelled by the next
|
||||
touch-down, clamped at the loaded content's start/end. Verified by unit
|
||||
test (fling distance against the closed-form spline result, cancel-on-
|
||||
touch, the clamp), not yet by an on-device or emulator feel-check --
|
||||
that is still open.
|
||||
- [x] **Scrolling down sometimes jitters the text, fixed 2026-09-06.**
|
||||
Root-caused by reading `DragArbiter::update`'s `Undecided`-to-`Panning`
|
||||
transition rather than by an on-device trace (no emulator was used this
|
||||
pass): it was the first named suspect, not the second. `self.last` stays
|
||||
at the press origin for every `Undecided` frame (nothing pans while the
|
||||
gesture might still be a selection), so the frame that finally crosses
|
||||
`DRAG_SLOP` returned `Pan(dy)` with `dy` measured from `press_start` --
|
||||
the *whole* pre-threshold drag, applied to the list in one step, however
|
||||
many frames it had taken to get there. Fixed by applying only the
|
||||
excess past `DRAG_SLOP` on that one frame (`dy - DRAG_SLOP.copysign
|
||||
(dy)`), the same "consume the slop, don't replay it" rule Android's own
|
||||
touch handling follows. New regression test,
|
||||
`crossing_the_slop_by_a_little_pans_by_a_little` (`iris/src/sense.rs`).
|
||||
**Not yet done**: an emulator trace of the real per-frame offset
|
||||
confirming this was the whole story on real touch input rather than
|
||||
only the arbiter's own unit tests -- worth a follow-up pass before
|
||||
calling it fully closed.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -410,8 +421,19 @@ do not duplicate it there.
|
||||
|
||||
## Build (asked for by Iris, 2026-09-06): a density-independent length unit
|
||||
|
||||
- [ ] **A third length kind beside relative and pixels, so display scales
|
||||
"just work".** Iris's words: "another length type similar to absolute &
|
||||
- [x] **A third length kind beside relative and pixels, so display scales
|
||||
"just work".** Done 2026-09-06 — `Len::dp`/`len_fns::dp`, resolved
|
||||
against `UiRenderState`/`Painter::density()` at `apply_rest` time; text
|
||||
additionally rasterises at the resolved (physical) size instead of
|
||||
scaling a low-resolution bitmap afterward, which was making text blurry.
|
||||
`Span::gap`/`Padding` moved from `f32` to `Len` so they take `dp(...)`
|
||||
too; transcript-ui's row/composer padding and one example migrated.
|
||||
`em` was not added — nothing in this pass needed a text-relative unit,
|
||||
and `dp`'s own doc says why it and physical pixels are kept as separate
|
||||
fields rather than one the caller pre-multiplies. Not yet verified on
|
||||
Iris's own phone at two densities (this pass had no device) — see
|
||||
docs/RUST.md's P0 box and docs/IRIS.md's 2026-09-06 entry for what to
|
||||
check. Iris's words: "another length type similar to absolute &
|
||||
relative, so instead there would be relative, pixels, and another unit
|
||||
like em or whatever is standard. That way different display scales
|
||||
should just work." Today a length is either a fraction of the parent
|
||||
|
||||
@@ -861,6 +861,58 @@ unspecified rather than getting them wrong:
|
||||
conditions, so the remaining slack was accepted rather than chased
|
||||
further.
|
||||
|
||||
## Density: `Len::dp`, resolved at `apply_rest` time (2026-09-06)
|
||||
|
||||
Iris asked for a third length kind beside `abs` (physical pixels) and
|
||||
`rel`/`rest` (a fraction of the parent) — IRIS_TODO.md's "density-
|
||||
independent length unit" — after the P0 phone pass found 16px text
|
||||
drawing at roughly a third size on a real phone. The fix that shipped
|
||||
first (RUST.md's P0 box) was a global stopgap: divide the whole window
|
||||
into a "logical" coordinate space (physical ÷ `content_scale`) and let
|
||||
the shader's NDC mapping stretch it back up onto the real framebuffer.
|
||||
That fixed the *size* but not the *sharpness* — a glyph rasterised at the
|
||||
small, pre-stretch size and then stretched onto more physical pixels than
|
||||
it has texels for is blurry, which is exactly what Iris's next report
|
||||
said.
|
||||
|
||||
**The fix**: `Len` gained a `dp` field, resolved against a `density: f32`
|
||||
(physical pixels per dp) at the one place a `Len` becomes a `UiScalar`
|
||||
(`Len::apply_rest`) — `abs + dp * density`. `density` lives on
|
||||
`UiRenderState` (`set_density`/`density()`) and `Painter` (`density()`),
|
||||
set once from `DisplayMetrics.density` in `android::view::new_peer`; the
|
||||
desktop backend has no per-monitor density wired up yet and stays at
|
||||
`1.0`. Every layout call site that used to call `.apply_rest()`/
|
||||
`.to_uivec2()` now passes `painter.density()` (nine call sites — `Span`,
|
||||
`Sized`, `MaxSize`, `Aligned`, `Scroll`, `List::place`, and
|
||||
`UiRenderState::reposition` itself). This also meant the Android
|
||||
boundary's global logical-space stopgap could come out entirely: window
|
||||
size, touch coordinates and insets are physical pixels again, matching
|
||||
`AndroidRenderer`'s own swapchain resolution, with `dp` doing the
|
||||
per-length work the global divide used to do for everything at once.
|
||||
|
||||
**Text is the case that needed more than the `Len` plumbing.** A widget's
|
||||
`font_size`/`line_height` are plain `f32`, not routed through `Len` at
|
||||
all (there is no sensible `rel`/`rest` for a font size). `TextBuffer::
|
||||
shape` now takes `density` directly and multiplies `font_size`/
|
||||
`line_height` (and any span override) by it before handing them to
|
||||
parley — so the size that reaches both the line-breaker and the
|
||||
rasteriser (`TextData::place`, which reads back whatever `shape` set) is
|
||||
the display's *physical* size, and the glyph atlas holds a bitmap at the
|
||||
resolution it is actually shown at. `GlyphKey.size` already keys on the
|
||||
resolved size, so a cache entry is naturally per-physical-size with no
|
||||
further change. The one caller with no `Painter` to read density from
|
||||
(`TextEditCtx::layout`, cursor movement and hit-testing) reads a second
|
||||
copy kept directly on `TextData` (`TextData::density`) instead — an
|
||||
accepted duplication rather than threading a `Painter` into every input
|
||||
handler for one field, the same tradeoff `AndroidRenderer::content_scale`
|
||||
already makes for the Diagnostics page.
|
||||
|
||||
**What did not change**: `rel`/`rest` are unaffected (already
|
||||
resolution-independent, a fraction of the parent). `Span::gap` and
|
||||
`Padding`'s four sides moved from bare `f32` to `Len` so `dp(...)` works
|
||||
on them the same as any other size; a bare number is still `abs`,
|
||||
physical pixels, unchanged.
|
||||
|
||||
## For IRIS.md
|
||||
|
||||
When this lands, copy this entry into `IRIS.md` (newest first):
|
||||
|
||||
+143
@@ -4438,6 +4438,149 @@ device.
|
||||
apk/release/iris-bench-arm64.apk`; that repo's own README gained a
|
||||
dated entry. Still not confirmed on Iris's actual phone.
|
||||
|
||||
**Redelivered again, 2026-09-06, a later pass.** Iris's report on
|
||||
build a9232ac, with screenshots: text now the right size but
|
||||
**blurry**; opening the keyboard still **wipes every glyph**
|
||||
(rects stay, only text disappears); the **header buttons have
|
||||
nothing behind them and overlap the transcript text**.
|
||||
|
||||
**1. The keyboard wipe.** Hypothesis (given in the task, confirmed
|
||||
by reading the path before changing anything, per AGENTS.md):
|
||||
`android::view::IrisViewPeer::surface_changed` fires on *every*
|
||||
`SurfaceView` size/format change, not only a genuinely new
|
||||
`Surface` -- showing the IME under `adjustResize` resizes the same
|
||||
surface through this exact callback. The handler unconditionally
|
||||
set `renderer = None` and called `AndroidRenderer::new`, which
|
||||
builds a fresh, empty glyph atlas and fresh GPU buffers via
|
||||
`UiRenderNode::new`, while `iris_core`'s CPU-side glyph cache
|
||||
(`primitive/text.rs`) kept the atlas UV coordinates it had already
|
||||
handed out against the *old* atlas -- every glyph then drew from a
|
||||
rectangle pointing into a texture that had just been recreated
|
||||
empty. Confirmed by reading `AndroidRenderer::resize` (already
|
||||
existed, already did none of that -- only `surface.configure` and
|
||||
the window uniform) against what `surface_changed` was actually
|
||||
calling instead. **Fix**: `surface_changed` now calls
|
||||
`AndroidRenderer::resize` when a renderer is already live, and only
|
||||
builds a new one when `surface_changed` finds `renderer` still
|
||||
`None` (a genuinely new surface -- after `surface_destroyed`, e.g.
|
||||
backgrounding). Not independently re-verified against a forced IME
|
||||
resize on this pass's emulator (no display keyboard exercised
|
||||
end-to-end here); the reasoning is a direct code read plus the
|
||||
existing `resize` path already being surface-only, not a
|
||||
screenshot diff -- **the next agent with emulator time should do
|
||||
the before/after screenshot this box originally asked for.**
|
||||
|
||||
**2. The blur.** Root cause: the P0 fix that made text the right
|
||||
*size* (dividing the whole window into a "logical" space, then
|
||||
letting the shader's NDC mapping stretch it back onto the real
|
||||
framebuffer) rasterised each glyph at the small, pre-stretch size
|
||||
and then displayed it stretched onto more physical pixels than it
|
||||
had texels for. **Fix, and the density-independent length unit
|
||||
Iris asked for the same day (IRIS_TODO.md) turned out to be the
|
||||
same fix**: `Len::dp`, resolved against a `density` now carried on
|
||||
`UiRenderState`/`Painter`, replaces the global stretch -- window
|
||||
size, touch and insets are physical pixels throughout again
|
||||
(`WindowInsets`, renamed from `LogicalInsets`), and
|
||||
`TextBuffer::shape` multiplies `font_size`/`line_height` by density
|
||||
before handing them to parley, so the atlas rasterises at the
|
||||
display's real physical resolution. Full design in docs/LAYOUT.md's
|
||||
"Density: `Len::dp`" section and the public-API summary in
|
||||
docs/IRIS.md's 2026-09-06 entry.
|
||||
|
||||
**3. The header.** Only each button's own `rect(...)` painted
|
||||
anything, so the gaps between/around them and the status-bar strip
|
||||
above showed `CLEAR_COLOR` (black) one layer back, and the row's
|
||||
reserved height was three `abs` (now-physical-pixel) button boxes
|
||||
-- smaller than the dp-correct size the transcript below uses,
|
||||
which is what read as "overlap" once the two disagreed. Fixed with
|
||||
a `HEADER_SURFACE` rect stacked behind the whole row and every
|
||||
header size moved onto `dp(...)`.
|
||||
|
||||
**4. Keyboard diagnostics, so Iris can report back even if a
|
||||
keyboard-triggered regression persists.** `on_insets_changed` now
|
||||
edge-triggers ~500ms after `ime_bottom` becomes non-zero, capturing
|
||||
the same report the on-screen Diagnostics button produces, logging
|
||||
it, copying it to the clipboard unprompted, and showing it in a new
|
||||
plain-view overlay (`IrisView.showDiagnosticsOverlay`, Copy/Close)
|
||||
that draws independently of iris's own renderer.
|
||||
|
||||
**Verified this pass**: `cargo fmt --all`, `cargo clippy --workspace
|
||||
--all-targets` and `cargo clippy` on `android-app` (both `-D
|
||||
warnings`, zero beyond the pre-existing `tabs-ui` unused-dependency
|
||||
and wgpu future-incompat notices), `cargo test --workspace` (all
|
||||
passing), `cargo ndk -t arm64-v8a check`/`clippy` for both the
|
||||
`transcript-screen bench` feature set.
|
||||
|
||||
**Then run on this checkout's own emulator** (x86_64 debug,
|
||||
`--features "transcript-screen force-gles bench"` -- this AVD has no
|
||||
Vulkan adapter under a plain `-gpu host` boot, matching every prior
|
||||
emulator finding in this file): `run-bench.sh` end to end, no crash,
|
||||
`frames=534 janky%=79.03 ... cpu_p50=1.3ms`, 24/24 swipes, 400/400
|
||||
streamed events -- unchanged in shape from prior readings, so the
|
||||
diff cost nothing on the success path. **Header background**:
|
||||
screenshot confirms the `HEADER_SURFACE` panel now sits behind all
|
||||
three buttons (`/tmp/bench-after-run.png` this pass). **Keyboard
|
||||
wipe**: forced a real `surface_changed` two ways -- `adb shell wm
|
||||
size 1080x1900` (screenshot before/after, text intact) and actually
|
||||
opening the soft keyboard via `settings put secure
|
||||
show_ime_with_hard_keyboard 1` + tapping the message field
|
||||
(ui-trace confirmed a real resize, elements moved -547px; keyboard
|
||||
visible in the screenshot, text still fully rendered, not wiped).
|
||||
Both are real evidence the reuse-renderer fix works, though neither
|
||||
is the literal before/after diff this box originally asked for --
|
||||
**still worth a deliberate side-by-side screenshot pair in a future
|
||||
pass.**
|
||||
|
||||
**Found during this same verification, not fixed, needs a follow-up
|
||||
pass**: after the keyboard-triggered resize, the top button row
|
||||
appeared to render a **second time**, well below its real position,
|
||||
inside the transcript's scroll area (same colours/text, unmistakably
|
||||
the same three buttons) -- and a tap aimed at the composer's
|
||||
"Message" field landed on "Run benchmark" instead (a second
|
||||
benchmark run started, visible in logcat as two `iris bench report:`
|
||||
lines from one session). Only seen after a resize with the keyboard
|
||||
genuinely open; the plain `wm size` resize screenshot pair did not
|
||||
show it, nor did the fresh-install screenshot before either resize.
|
||||
**Not root-caused this pass** -- time ran out before isolating
|
||||
whether this is the `Span::DOWN` two-phase draw (LAYOUT.md's
|
||||
provisional-then-real placement) leaving a phase-1 primitive
|
||||
retained somewhere it should have been moved from, something
|
||||
specific to the keyboard's `on_insets_changed` rebuild racing a
|
||||
redraw, or unrelated to this pass's changes entirely (not verified
|
||||
against a build predating this session's commits, so do not treat
|
||||
"caused by this pass" as established -- MACHINE.md's pinned rule
|
||||
about not attributing without measuring applies here too). Also
|
||||
noteworthy: `capture_keyboard_diagnostics` never fired in this
|
||||
session (no "iris keyboard diagnostics" log line) despite the
|
||||
keyboard visibly opening -- `on_insets_changed`'s `ime_bottom` may
|
||||
not be populated the way expected on this emulator/API level, or
|
||||
the duplicate-row state above interfered; **also needs a follow-up
|
||||
pass** before relying on the auto-capture on a real phone.
|
||||
|
||||
**Not verified this pass**: anything on Iris's real phone, the
|
||||
two-density crispness check IRIS_TODO.md's unit item asks for, and
|
||||
the two open items just above.
|
||||
|
||||
**Fling and jitter, 2026-09-06.** The two `IRIS_TODO.md` "From the
|
||||
phone" items this box's own text names as follow-ups are fixed --
|
||||
`List::fling`/`VelocityTracker`/`FlingCalculator` (IRIS.md's
|
||||
2026-09-06 entry) and the `DragArbiter` slop-release jump (fixed by
|
||||
applying only the excess past `DRAG_SLOP` on the crossing frame,
|
||||
not the whole pre-threshold drag) -- both wired through
|
||||
`Selection::drag`'s release path, both covered by new unit tests in
|
||||
`iris/src/sense.rs` and `iris/src/widget/list.rs`. **Root-caused by
|
||||
reading `DragArbiter::update` and testing it directly, not by an
|
||||
emulator trace** -- this pass did not open an emulator, so the
|
||||
"trace the list's offset per frame" verification this box's own
|
||||
todo asked for is still open, as is a feel-check of the fling on
|
||||
real touch input. **Benchmark v2's four-phase spec (fling/stream/
|
||||
type/keyboard) in `bench_client.rs` was not attempted this pass** --
|
||||
wiring a real IME show/hide and refresh-rate read through
|
||||
`bench_jni.rs`, and `FrameReport`'s per-phase accounting, is real
|
||||
scope on its own and was left rather than shipped half-verified;
|
||||
the Compose half above is already done and is the reference shape
|
||||
for whoever picks this up. No redelivery this pass.
|
||||
|
||||
- [ ] **P1 — session screen parity.** History paging backward (with the
|
||||
page-boundary healing `client-core` does not have yet, below),
|
||||
`TranscriptSource`-backed cache/server stitching, jump-to-latest,
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
package dev.iris.android.demo;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
|
||||
@@ -68,4 +75,81 @@ public final class IrisView extends RustView {
|
||||
scroll.addView(text);
|
||||
activity.setContentView(scroll);
|
||||
}
|
||||
|
||||
private static final String DIAGNOSTICS_OVERLAY_TAG = "iris-diagnostics-overlay";
|
||||
|
||||
/**
|
||||
* The bench build's keyboard diagnostics capture
|
||||
* (`bench_client.rs`'s `on_insets_changed` /
|
||||
* `capture_keyboard_diagnostics`, via `bench_jni.rs`'s
|
||||
* `PlatformHandle::show_diagnostics_overlay`): unlike
|
||||
* `showRendererError` above, this adds a panel *over* this view
|
||||
* (`MainActivity`'s `FrameLayout` still holds `IrisView` underneath,
|
||||
* running) rather than replacing the activity's content, and gives it
|
||||
* a Copy button and a Close that removes the panel -- so it draws
|
||||
* (and can be read) whether or not iris itself is still putting
|
||||
* anything on screen, without abandoning the session that produced
|
||||
* it. Runs on the UI thread regardless of which thread calls it,
|
||||
* since the call comes from a background task (a delayed capture
|
||||
* after the keyboard opens), and touching the view tree off the UI
|
||||
* thread is undefined.
|
||||
*/
|
||||
void showDiagnosticsOverlay(String report) {
|
||||
Context context = getContext();
|
||||
if (!(context instanceof Activity)) {
|
||||
return;
|
||||
}
|
||||
Activity activity = (Activity) context;
|
||||
activity.runOnUiThread(() -> {
|
||||
ViewGroup parent = (ViewGroup) getParent();
|
||||
if (parent == null) {
|
||||
return;
|
||||
}
|
||||
View existing = parent.findViewWithTag(DIAGNOSTICS_OVERLAY_TAG);
|
||||
if (existing != null) {
|
||||
parent.removeView(existing);
|
||||
}
|
||||
|
||||
float density = activity.getResources().getDisplayMetrics().density;
|
||||
int pad = (int) (16 * density);
|
||||
|
||||
LinearLayout overlay = new LinearLayout(activity);
|
||||
overlay.setTag(DIAGNOSTICS_OVERLAY_TAG);
|
||||
overlay.setOrientation(LinearLayout.VERTICAL);
|
||||
overlay.setBackgroundColor(0xEE000000);
|
||||
overlay.setPadding(pad, pad, pad, pad);
|
||||
|
||||
TextView text = new TextView(activity);
|
||||
text.setText(report);
|
||||
text.setTextIsSelectable(true);
|
||||
text.setTextColor(0xFFFFFFFF);
|
||||
ScrollView scroll = new ScrollView(activity);
|
||||
scroll.addView(text);
|
||||
overlay.addView(scroll, new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f));
|
||||
|
||||
LinearLayout buttonRow = new LinearLayout(activity);
|
||||
buttonRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||
buttonRow.setPadding(0, pad, 0, 0);
|
||||
|
||||
Button copy = new Button(activity);
|
||||
copy.setText("Copy");
|
||||
copy.setOnClickListener(v -> {
|
||||
ClipboardManager clipboard =
|
||||
(ClipboardManager) activity.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
if (clipboard != null) {
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("iris diagnostics", report));
|
||||
}
|
||||
});
|
||||
Button close = new Button(activity);
|
||||
close.setText("Close");
|
||||
close.setOnClickListener(v -> parent.removeView(overlay));
|
||||
buttonRow.addView(copy);
|
||||
buttonRow.addView(close);
|
||||
overlay.addView(buttonRow);
|
||||
|
||||
parent.addView(overlay, new FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,13 @@ pub struct BenchClient {
|
||||
platform: Option<Arc<PlatformHandle>>,
|
||||
last_report: Option<String>,
|
||||
running: bool,
|
||||
/// Edge-triggers the keyboard diagnostics capture below -- set on the
|
||||
/// first `on_insets_changed` where `ime_bottom > 0.0`, cleared on the
|
||||
/// first where it is not, so opening the keyboard fires this once
|
||||
/// rather than on every insets update while it stays open (a rotation
|
||||
/// or a status-bar change with the keyboard already up would otherwise
|
||||
/// re-fire it).
|
||||
keyboard_was_visible: bool,
|
||||
}
|
||||
|
||||
impl HasAndroidUiState for BenchClient {
|
||||
@@ -184,7 +191,7 @@ impl AndroidAppState for BenchClient {
|
||||
let tree = (
|
||||
top_bar,
|
||||
content.height(rest(2)),
|
||||
report_display.height(rest(1)).pad(8),
|
||||
report_display.height(rest(1)).pad(dp(8)),
|
||||
)
|
||||
.span(Dir::DOWN)
|
||||
.add_strong(rsc)
|
||||
@@ -219,6 +226,7 @@ impl AndroidAppState for BenchClient {
|
||||
platform: None,
|
||||
last_report: None,
|
||||
running: false,
|
||||
keyboard_was_visible: false,
|
||||
};
|
||||
|
||||
let (backlog, stream_tail) = parse_fixture();
|
||||
@@ -246,19 +254,79 @@ impl AndroidAppState for BenchClient {
|
||||
/// Pads the top button row by the status-bar inset -- see `top_bar`'s
|
||||
/// field comment. Rebuilds the row rather than mutating a stored
|
||||
/// `Padding` in place, since nothing here holds a handle to one.
|
||||
fn on_insets_changed(&mut self, rsc: &mut AndroidRsc<Self>, insets: iris::android::LogicalInsets) {
|
||||
///
|
||||
/// **Also the trigger for the keyboard diagnostics capture** (RUST.md's
|
||||
/// P0 box): the IME resizing the surface is exactly the case the
|
||||
/// previous commit found wiped text, and Iris needs a way to get a
|
||||
/// report off the phone even if that (or some other keyboard-triggered
|
||||
/// regression) is still happening on the build she is holding --
|
||||
/// `capture_keyboard_diagnostics` below fires ~500ms after the
|
||||
/// keyboard becomes visible, once per keyboard opening, and shows its
|
||||
/// report in a plain overlay view that draws independently of
|
||||
/// whatever iris itself is doing.
|
||||
fn on_insets_changed(
|
||||
&mut self,
|
||||
rsc: &mut AndroidRsc<Self>,
|
||||
insets: iris::android::WindowInsets,
|
||||
) {
|
||||
let controls = bench_controls(rsc, insets.top);
|
||||
(self.top_bar)(rsc).set(controls);
|
||||
|
||||
let ime_visible = insets.ime_bottom > 0.0;
|
||||
if ime_visible && !self.keyboard_was_visible {
|
||||
self.keyboard_was_visible = true;
|
||||
let redraw = rsc.tasks.redraw_handle();
|
||||
rsc.spawn_task(async move |mut ctx| {
|
||||
tokio::time::sleep(Duration::from_millis(KEYBOARD_DIAGNOSTICS_DELAY_MS)).await;
|
||||
ctx.update(|state: &mut BenchClient, rsc| {
|
||||
state.capture_keyboard_diagnostics(rsc);
|
||||
});
|
||||
redraw.request_redraw();
|
||||
});
|
||||
} else if !ime_visible {
|
||||
self.keyboard_was_visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long to wait after the keyboard becomes visible before capturing
|
||||
/// diagnostics -- long enough that the resize, the reported wipe (if it is
|
||||
/// still happening) and a couple of frames have all had time to land, per
|
||||
/// AGENTS.md's "so that operations that finish in milliseconds have states
|
||||
/// on the way that nothing can observe" reasoning applied the other way:
|
||||
/// this wants to observe the state *after* the transition settles, not
|
||||
/// mid-flight.
|
||||
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
|
||||
|
||||
type Rsc = AndroidRsc<BenchClient>;
|
||||
|
||||
/// `top_pad` is the status-bar inset in logical units (0.0 until
|
||||
/// The header row's own backdrop -- see `bench_controls`'s doc comment on
|
||||
/// why it needs one at all. A dark neutral rather than pure black
|
||||
/// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel
|
||||
/// instead of a hole in the background the buttons happen to float in.
|
||||
const HEADER_SURFACE: UiColor = UiColor::new(28, 28, 34, 255);
|
||||
|
||||
/// `top_pad` is the status-bar inset in physical pixels (0.0 until
|
||||
/// `on_insets_changed` has run once) -- folded in here, rather than
|
||||
/// exposing the unadded builder for a caller to `.pad()` itself, because
|
||||
/// naming that builder's type at each call site is more machinery than a
|
||||
/// top-of-screen padding number is worth.
|
||||
///
|
||||
/// **Backed by an opaque rect the full size of the row, not just the three
|
||||
/// buttons.** Iris's phone report (docs/RUST.md's P0 box, screenshots on
|
||||
/// build a9232ac): "the header buttons have nothing behind them and
|
||||
/// overlap the transcript text" -- before this, only each button's own
|
||||
/// `rect(...)` painted anything, so the gaps between and around them (and
|
||||
/// the status-bar strip above them) showed whatever was one layer back
|
||||
/// (`CLEAR_COLOR`, black), and the row's true height was three
|
||||
/// physical-pixel-sized (`abs`, not `dp`) button boxes rather than the
|
||||
/// density-correct size the transcript below was already using post-P0 --
|
||||
/// exactly what reads as "overlap" once the two disagree. Fixed two ways
|
||||
/// together: a `HEADER_SURFACE` rect stacked behind the whole row (this
|
||||
/// function), and every size below moved from a bare number (physical
|
||||
/// pixels) to `dp(...)` (IRIS_TODO.md's density-independent length unit),
|
||||
/// so the row's reserved height in the outer `Span::DOWN`
|
||||
/// (`AndroidAppState::new`) matches what is actually painted.
|
||||
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||
let run_rect = rect(Color::rgb(40, 70, 40))
|
||||
.on(
|
||||
@@ -273,7 +341,7 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||
wtext("Run benchmark").size(18).text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(8)
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
let copy_rect = rect(Color::rgb(50, 50, 60))
|
||||
@@ -289,7 +357,7 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||
wtext("Copy report").size(18).text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(8)
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
let diag_rect = rect(Color::rgb(60, 45, 70))
|
||||
@@ -305,12 +373,14 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
|
||||
wtext("Diagnostics").size(18).text_align(Align::CENTER),
|
||||
)
|
||||
.stack()
|
||||
.pad(8)
|
||||
.pad(dp(8))
|
||||
.add(rsc);
|
||||
|
||||
(run, copy, diagnostics)
|
||||
.span(Dir::RIGHT)
|
||||
.height(56)
|
||||
let buttons = (run, copy, diagnostics).span(Dir::RIGHT).add(rsc);
|
||||
|
||||
(rect(HEADER_SURFACE), buttons)
|
||||
.stack()
|
||||
.height(dp(56))
|
||||
.pad(Padding::top(top_pad))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
@@ -350,6 +420,36 @@ impl BenchClient {
|
||||
self.last_report = Some(report);
|
||||
}
|
||||
|
||||
/// The keyboard's own diagnostics capture -- see `on_insets_changed`'s
|
||||
/// doc comment. Reuses `show_diagnostics`'s exact report (so it is the
|
||||
/// same text the on-screen `Diagnostics` button produces, plus the
|
||||
/// per-frame log `FrameReport` already keeps around the resize --
|
||||
/// `frame_report.report()` above covers "the frames around the
|
||||
/// resize" without a second accounting mechanism), then does three
|
||||
/// things the button does not: logs it (so a `logcat` pull gets it
|
||||
/// even if nothing on screen does), copies it to the clipboard
|
||||
/// unprompted, and shows it in the shell's plain overlay view, which
|
||||
/// draws independently of iris's own renderer -- the whole point,
|
||||
/// since the renderer is exactly what might be in the wiped state
|
||||
/// this exists to report on.
|
||||
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
|
||||
self.show_diagnostics(rsc);
|
||||
let Some(report) = self.last_report.clone() else {
|
||||
return;
|
||||
};
|
||||
log::info!("iris keyboard diagnostics:\n{report}");
|
||||
let Some(platform) = &self.platform else {
|
||||
log::info!("iris keyboard diagnostics: no platform handle, can't reach the shell");
|
||||
return;
|
||||
};
|
||||
if platform.copy_to_clipboard("iris keyboard diagnostics", &report) {
|
||||
log::info!("iris keyboard diagnostics: copied to clipboard");
|
||||
} else {
|
||||
log::info!("iris keyboard diagnostics: clipboard copy failed");
|
||||
}
|
||||
platform.show_diagnostics_overlay(&report);
|
||||
}
|
||||
|
||||
fn copy_report(&mut self) {
|
||||
let Some(report) = &self.last_report else {
|
||||
log::info!("iris bench report: nothing to copy -- run the benchmark first");
|
||||
|
||||
@@ -131,4 +131,31 @@ impl PlatformHandle {
|
||||
.ok()?;
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Shows `report` in the shell's plain-view diagnostics overlay
|
||||
/// (`IrisView.showDiagnosticsOverlay`) -- a real `TextView` plus Copy
|
||||
/// and Close controls, added over whatever iris itself is drawing
|
||||
/// rather than replacing it (unlike `android::view::show_renderer_error`,
|
||||
/// which exists for the case the renderer can never recover from and
|
||||
/// intentionally never returns). Called from a background task after
|
||||
/// the keyboard-open delay (`bench_client.rs`'s `on_insets_changed`),
|
||||
/// so the Java side hops onto the UI thread itself before touching the
|
||||
/// view tree -- see that method's own comment.
|
||||
pub fn show_diagnostics_overlay(&self, report: &str) -> bool {
|
||||
self.try_show_diagnostics_overlay(report).is_some()
|
||||
}
|
||||
|
||||
fn try_show_diagnostics_overlay(&self, report: &str) -> Option<()> {
|
||||
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||
let env: &mut JNIEnv = &mut guard;
|
||||
let jreport = env.new_string(report).ok()?;
|
||||
env.call_method(
|
||||
self.view.as_obj(),
|
||||
"showDiagnosticsOverlay",
|
||||
"(Ljava/lang/String;)V",
|
||||
&[JValue::Object(jreport.as_ref())],
|
||||
)
|
||||
.ok()?;
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,31 @@ pub struct Size {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
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,
|
||||
/// Density-independent pixels -- Android's `dp` / CSS's reference pixel
|
||||
/// (1 unit = 1/160in), resolved against the display's density at
|
||||
/// layout time (`apply_rest`'s `density` parameter) rather than at the
|
||||
/// point a widget is built, since density is a property of the device
|
||||
/// this ends up running on, not of the widget tree. This is the unit
|
||||
/// IRIS_TODO.md's "a density-independent length unit" item asked for,
|
||||
/// 2026-09-06: before it existed, every size in the tree was `abs`
|
||||
/// (physical pixels), and the only way to make a 16px design draw at
|
||||
/// the right *size* on a denser display was a single global multiply
|
||||
/// applied to the whole rendered scene after layout -- which is also
|
||||
/// what made text blurry (RUST.md's P0 box, "blurry ... glyphs drawn
|
||||
/// at logical size and stretched by the scale"): a glyph rasterised at
|
||||
/// 16 physical px and then stretched 3x by that global multiply is a
|
||||
/// 48px area sampled from a 16px bitmap. Resolving `dp` per-length at
|
||||
/// layout time instead means the font size handed to the text shaper
|
||||
/// is already the physical size (`16.0.dp() * 3.0`), so the glyph
|
||||
/// atlas rasterises at the display's real resolution and nothing
|
||||
/// downstream needs to stretch anything.
|
||||
pub dp: f32,
|
||||
pub rel: f32,
|
||||
pub rest: f32,
|
||||
}
|
||||
@@ -67,10 +91,10 @@ impl Size {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_uivec2(self) -> UiVec2 {
|
||||
pub fn to_uivec2(self, density: f32) -> UiVec2 {
|
||||
UiVec2 {
|
||||
x: self.x.apply_rest(),
|
||||
y: self.y.apply_rest(),
|
||||
x: self.x.apply_rest(density),
|
||||
y: self.y.apply_rest(density),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,26 +122,43 @@ impl Size {
|
||||
impl Len {
|
||||
pub const ZERO: Self = Self {
|
||||
abs: 0.0,
|
||||
dp: 0.0,
|
||||
rel: 0.0,
|
||||
rest: 0.0,
|
||||
};
|
||||
|
||||
pub const REST: Self = Self {
|
||||
abs: 0.0,
|
||||
dp: 0.0,
|
||||
rel: 0.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 {
|
||||
rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 },
|
||||
abs: self.abs,
|
||||
abs: self.abs + self.dp * density,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn abs(abs: impl UiNum) -> Self {
|
||||
Self {
|
||||
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,
|
||||
rest: 0.0,
|
||||
}
|
||||
@@ -125,6 +166,7 @@ impl Len {
|
||||
pub fn rel(rel: impl UiNum) -> Self {
|
||||
Self {
|
||||
abs: 0.0,
|
||||
dp: 0.0,
|
||||
rel: rel.to_f32(),
|
||||
rest: 0.0,
|
||||
}
|
||||
@@ -132,6 +174,7 @@ impl Len {
|
||||
pub fn rest(ratio: impl UiNum) -> Self {
|
||||
Self {
|
||||
abs: 0.0,
|
||||
dp: 0.0,
|
||||
rel: 0.0,
|
||||
rest: ratio.to_f32(),
|
||||
}
|
||||
@@ -144,6 +187,15 @@ pub mod len_fns {
|
||||
pub fn abs(abs: impl UiNum) -> Len {
|
||||
Len {
|
||||
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,
|
||||
rest: 0.0,
|
||||
}
|
||||
@@ -151,6 +203,7 @@ pub mod len_fns {
|
||||
pub fn rel(rel: impl UiNum) -> Len {
|
||||
Len {
|
||||
abs: 0.0,
|
||||
dp: 0.0,
|
||||
rel: rel.to_f32(),
|
||||
rest: 0.0,
|
||||
}
|
||||
@@ -158,14 +211,15 @@ pub mod len_fns {
|
||||
pub fn rest(ratio: impl UiNum) -> Len {
|
||||
Len {
|
||||
abs: 0.0,
|
||||
dp: 0.0,
|
||||
rel: 0.0,
|
||||
rest: ratio.to_f32(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl_op!(Len Add add; abs rel rest);
|
||||
impl_op!(Len Sub sub; abs rel rest);
|
||||
impl_op!(Len Add add; abs dp rel rest);
|
||||
impl_op!(Len Sub sub; abs dp rel rest);
|
||||
|
||||
impl_op!(Size Add add; x y);
|
||||
impl_op!(Size Sub sub; x y);
|
||||
@@ -187,6 +241,9 @@ impl std::fmt::Display for Len {
|
||||
if self.abs != 0.0 {
|
||||
write!(f, "{} abs;", self.abs)?;
|
||||
}
|
||||
if self.dp != 0.0 {
|
||||
write!(f, "{} dp;", self.dp)?;
|
||||
}
|
||||
if self.rel != 0.0 {
|
||||
write!(f, "{} rel;", self.rel)?;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,17 @@ pub struct TextData {
|
||||
pub layout_cx: LayoutContext<UiColor>,
|
||||
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,
|
||||
}
|
||||
|
||||
impl Default for TextData {
|
||||
@@ -75,6 +86,7 @@ impl Default for TextData {
|
||||
layout_cx: LayoutContext::new(),
|
||||
scale_cx: ScaleContext::new(),
|
||||
atlas: GlyphAtlas::default(),
|
||||
density: 1.0,
|
||||
};
|
||||
data.register_bundled_fonts();
|
||||
data
|
||||
@@ -363,7 +375,7 @@ pub struct TextBuffer {
|
||||
/// `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>)>,
|
||||
shaped: Option<(TextAttrs, Option<f32>, f32)>,
|
||||
}
|
||||
|
||||
impl TextBuffer {
|
||||
@@ -419,19 +431,42 @@ impl TextBuffer {
|
||||
Vec2::new(self.layout.width(), self.layout.height())
|
||||
}
|
||||
|
||||
/// Lay the text out, unless it is already laid out for these attributes and
|
||||
/// this width.
|
||||
pub fn shape(&mut self, data: &mut TextData, attrs: &TextAttrs, width: Option<f32>) {
|
||||
if self.shaped.as_ref() == Some(&(attrs.clone(), width)) {
|
||||
/// 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;
|
||||
}
|
||||
let mut builder = data
|
||||
.layout_cx
|
||||
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
|
||||
builder.push_default(StyleProperty::FontFamily(attrs.family.family()));
|
||||
builder.push_default(StyleProperty::FontSize(attrs.font_size));
|
||||
builder.push_default(StyleProperty::FontSize(attrs.font_size * density));
|
||||
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
|
||||
attrs.line_height,
|
||||
attrs.line_height * density,
|
||||
)));
|
||||
builder.push_default(StyleProperty::Brush(attrs.color));
|
||||
for span in &self.spans {
|
||||
@@ -443,7 +478,7 @@ impl TextBuffer {
|
||||
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
|
||||
}
|
||||
if let Some(size) = span.font_size {
|
||||
builder.push(StyleProperty::FontSize(size), range.clone());
|
||||
builder.push(StyleProperty::FontSize(size * density), range.clone());
|
||||
}
|
||||
if span.bold {
|
||||
builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone());
|
||||
@@ -459,7 +494,7 @@ impl TextBuffer {
|
||||
self.layout.break_all_lines(width);
|
||||
self.layout
|
||||
.align(Alignment::Start, AlignmentOptions::default());
|
||||
self.shaped = Some((attrs.clone(), width));
|
||||
self.shaped = Some((attrs.clone(), width, density));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,8 +611,9 @@ impl TextData {
|
||||
attrs: &TextAttrs,
|
||||
width: Option<f32>,
|
||||
textures: &mut Textures,
|
||||
density: f32,
|
||||
) -> RenderedText {
|
||||
buffer.shape(self, attrs, width);
|
||||
buffer.shape(self, attrs, width, density);
|
||||
let glyphs = self.place(buffer, textures);
|
||||
RenderedText {
|
||||
glyphs: std::sync::Arc::new(glyphs),
|
||||
|
||||
@@ -165,8 +165,10 @@ impl<'a> Painter<'a> {
|
||||
attrs: &TextAttrs,
|
||||
width: Option<f32>,
|
||||
) -> RenderedText {
|
||||
let density = self.state.density;
|
||||
let ui = self.rsc.ui_mut();
|
||||
ui.text.render(buffer, attrs, width, &mut ui.textures)
|
||||
ui.text
|
||||
.render(buffer, attrs, width, &mut ui.textures, density)
|
||||
}
|
||||
|
||||
/// Draw a laid-out string: one quad per glyph, all sampling the atlas.
|
||||
@@ -210,6 +212,12 @@ impl<'a> Painter<'a> {
|
||||
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 {
|
||||
self.region.size().to_abs(self.state.output_size)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,12 @@ pub struct UiRenderState {
|
||||
pub active: HashMap<WidgetId, ActiveData>,
|
||||
pub layers: PrimitiveLayers,
|
||||
pub(super) output_size: Vec2,
|
||||
/// Physical pixels per `dp` -- see `Len::dp`'s field doc. `1.0` (an
|
||||
/// unscaled display) until a backend that knows its own density calls
|
||||
/// `set_density` (Android's `content_scale`, read at `surface_changed`
|
||||
/// time); the winit backend has no analogous per-monitor value wired up
|
||||
/// yet and stays at the default.
|
||||
pub(super) density: f32,
|
||||
|
||||
old_root: Option<WidgetId>,
|
||||
resized: bool,
|
||||
@@ -35,6 +41,7 @@ impl UiRenderState {
|
||||
active: Default::default(),
|
||||
layers: Default::default(),
|
||||
output_size: Vec2::ZERO,
|
||||
density: 1.0,
|
||||
old_root: None,
|
||||
resized: false,
|
||||
draw_started: Default::default(),
|
||||
@@ -60,6 +67,20 @@ impl UiRenderState {
|
||||
self.resized = true;
|
||||
}
|
||||
|
||||
/// Sets the physical-pixels-per-dp ratio every `Len::dp` in the tree
|
||||
/// resolves against from the next layout pass on -- see `density`'s
|
||||
/// field doc. Not folded into `resize` because the two change on
|
||||
/// different triggers (a surface resize on every rotation or keyboard
|
||||
/// open; a density change only if the app follows the display to a
|
||||
/// different screen, which Android surfaces separately).
|
||||
pub fn set_density(&mut self, density: f32) {
|
||||
self.density = density;
|
||||
}
|
||||
|
||||
pub fn density(&self) -> f32 {
|
||||
self.density
|
||||
}
|
||||
|
||||
pub fn update<'a>(&mut self, root: impl Into<Option<&'a StrongWidget>>, rsc: &mut dyn UiRsc) {
|
||||
// safety mechanism for memory leaks; might wanna return a result instead so user can
|
||||
// decide whether to panic or not
|
||||
@@ -311,7 +332,7 @@ impl UiRenderState {
|
||||
};
|
||||
let from = active
|
||||
.size
|
||||
.to_uivec2()
|
||||
.to_uivec2(self.density)
|
||||
.align(RegionAlign::TOP_LEFT)
|
||||
.within(&active.region);
|
||||
let slot = active.move_slot;
|
||||
|
||||
@@ -68,12 +68,15 @@ fn build_row<Rsc: UiRsc + 'static>(rsc: &mut Rsc, i: usize) -> StrongWidget {
|
||||
let mut span = Span::empty(Dir::DOWN);
|
||||
span.push(text);
|
||||
span.push(img);
|
||||
span.pad(8.0).background(rect(tint)).add_strong(rsc).any()
|
||||
span.pad(dp(8.0))
|
||||
.background(rect(tint))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
} else {
|
||||
wtext(row_text(i))
|
||||
.wrap(true)
|
||||
.color(text_color)
|
||||
.pad(8.0)
|
||||
.pad(dp(8.0))
|
||||
.background(rect(tint))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
|
||||
@@ -23,7 +23,7 @@ mod view;
|
||||
pub use insets::Insets;
|
||||
pub use render::AndroidRenderer;
|
||||
pub use view::{
|
||||
AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState, IrisViewPeer, LogicalInsets,
|
||||
AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState, IrisViewPeer, WindowInsets,
|
||||
new_peer,
|
||||
};
|
||||
|
||||
|
||||
+21
-19
@@ -208,14 +208,14 @@ impl AndroidRenderer {
|
||||
surface.configure(&device, &config);
|
||||
|
||||
let encoder = Self::create_encoder(&device);
|
||||
// Logical size (physical / `content_scale`) -- see
|
||||
// `android::view::AndroidUiState::content_scale`'s field comment
|
||||
// for why this crate now divides at all (RUST.md's P0 box, "text
|
||||
// is far too small"). The swapchain above stays at the real
|
||||
// physical `width`/`height` for a sharp framebuffer.
|
||||
let logical_size =
|
||||
iris_core::util::Vec2::new(width as f32 / content_scale, height as f32 / content_scale);
|
||||
let ui = match UiRenderNode::new(&device, &queue, &config, logical_size) {
|
||||
// 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)),
|
||||
};
|
||||
@@ -398,25 +398,27 @@ impl AndroidRenderer {
|
||||
submit_start.elapsed()
|
||||
}
|
||||
|
||||
/// Logical size (physical / `content_scale`) -- the unit layout and
|
||||
/// hit-testing use, matching the window uniform's own units. See
|
||||
/// 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.content_scale,
|
||||
self.config.height as f32 / self.content_scale,
|
||||
)
|
||||
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 logical = iris_core::util::Vec2::new(
|
||||
width as f32 / self.content_scale,
|
||||
height as f32 / self.content_scale,
|
||||
);
|
||||
self.ui.resize(logical, &self.queue);
|
||||
let size = iris_core::util::Vec2::new(width as f32, height as f32);
|
||||
self.ui.resize(size, &self.queue);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+116
-69
@@ -70,19 +70,31 @@ pub struct AndroidUiState {
|
||||
/// because `dumpsys gfxinfo` cannot see a `SurfaceView`'s own
|
||||
/// GPU-drawn frames at all. See `iris_core::FrameReport`'s own doc.
|
||||
pub frame_report: FrameReport,
|
||||
/// `DisplayMetrics.density` (`new_peer`'s doc comment), read once at
|
||||
/// view construction: physical pixels per dp on this device. Neither
|
||||
/// this crate nor `default::` had ever divided by it before RUST.md's
|
||||
/// P0 box's phone report ("text is far too small") -- `window_size`
|
||||
/// below and `surface_changed`'s call into `UiRenderState::resize` both
|
||||
/// report *logical* (physical / `content_scale`) dimensions now, which
|
||||
/// is what makes a `font_size: 16.0` 16 dp rather than 16 raw device
|
||||
/// pixels on a ~3x-density phone. The actual wgpu surface/swapchain
|
||||
/// stays at the real physical resolution (`AndroidRenderer`'s own
|
||||
/// `config.width/height`) for a sharp framebuffer; only the *logical*
|
||||
/// coordinate system layout, hit-testing and the window uniform agree
|
||||
/// on is scaled. Touch coordinates (`on_touch_event`) are divided by
|
||||
/// this too, so they land in the same space layout is using.
|
||||
/// `DisplayMetrics.density` (`new_peer`'s doc comment): physical pixels
|
||||
/// per dp on this device, read once at view construction and carried
|
||||
/// on `UiRenderState::density` (`render.set_density`, `new_peer`) from
|
||||
/// then on -- every `Len::dp` in the widget tree resolves against it at
|
||||
/// layout time (`Len::dp`'s field doc, IRIS_TODO.md's
|
||||
/// "density-independent length unit" item, 2026-09-06).
|
||||
///
|
||||
/// **Everything else in this module is physical pixels, matching the
|
||||
/// real wgpu surface/swapchain resolution** -- window size, touch
|
||||
/// coordinates, insets. That is a correction from an earlier version
|
||||
/// of this comment, which had `window_size`/`surface_changed`'s
|
||||
/// `UiRenderState::resize` call divide by `content_scale` into a
|
||||
/// *logical* coordinate space instead, as a global stopgap for
|
||||
/// RUST.md's P0 box's phone report ("text is far too small"). That
|
||||
/// stopgap fixed the size but not the *sharpness*: dividing to logical
|
||||
/// units meant a `16.0`-sized glyph rasterised at 16 physical px and
|
||||
/// then implicitly upscaled ~3x by the NDC mapping onto the real
|
||||
/// physical framebuffer -- the exact "blurry ... glyphs drawn at
|
||||
/// logical size and stretched by the scale" Iris reported next.
|
||||
/// Resolving `dp` at layout time replaces it: a widget author writes
|
||||
/// `dp(16)` for a size that should look the same physical size on any
|
||||
/// density, and everything downstream (layout, hit-testing, the window
|
||||
/// uniform, and the font size handed to the text shaper) works in the
|
||||
/// display's own physical pixels throughout, so nothing is
|
||||
/// rasterised at one resolution and displayed at another.
|
||||
pub content_scale: f32,
|
||||
/// The last insets `render()` saw -- compared each frame so
|
||||
/// `AndroidAppState::on_insets_changed` fires only when they actually
|
||||
@@ -154,21 +166,26 @@ pub trait AndroidAppState: HasAndroidUiState {
|
||||
/// (RUST.md's P0 box: "the status-bar inset is not applied" reported
|
||||
/// the two top buttons sitting under it, because nothing read `.top`
|
||||
/// at all), and again on a rotation or the keyboard opening/closing.
|
||||
/// `insets` is in the same *logical* units `content_scale` converts
|
||||
/// everything else to (physical / `content_scale`), so a widget can add
|
||||
/// it to a layout size directly. The default does nothing -- most
|
||||
/// screens have no chrome that sits under a system bar.
|
||||
/// `insets` is in the same physical-pixel units everything else in the
|
||||
/// tree now uses (`AndroidUiState::content_scale`'s field comment), so
|
||||
/// a widget can add it to a layout size directly -- `dp(...) +
|
||||
/// abs(insets.top)` if the widget wants a density-independent size
|
||||
/// plus the system bar's own (already-physical) height. The default
|
||||
/// does nothing -- most screens have no chrome that sits under a
|
||||
/// system bar.
|
||||
#[allow(unused_variables)]
|
||||
fn on_insets_changed(&mut self, rsc: &mut AndroidRsc<Self>, insets: LogicalInsets) {}
|
||||
fn on_insets_changed(&mut self, rsc: &mut AndroidRsc<Self>, insets: WindowInsets) {}
|
||||
}
|
||||
|
||||
/// `insets::Insets`, converted from physical to logical units -- see
|
||||
/// `AndroidUiState::content_scale`'s field comment. A distinct type from
|
||||
/// `insets::Insets` (rather than dividing in place) so a reader at the call
|
||||
/// site can tell which unit a value is already in without checking where it
|
||||
/// came from.
|
||||
/// `insets::Insets` as `f32`, for the widget-facing callback above -- a
|
||||
/// distinct type from `insets::Insets` so a caller of `on_insets_changed`
|
||||
/// is not coupled to that module's own (`i32`, JNI-shaped) representation.
|
||||
/// Both are physical pixels; this used to divide by `content_scale` into a
|
||||
/// separate *logical* unit (hence the old name, `LogicalInsets`), back when
|
||||
/// the rest of layout was logical too -- see `AndroidUiState::content_scale`'s
|
||||
/// field comment for why that stopgap is gone.
|
||||
#[derive(Clone, Copy, Default, Debug, PartialEq)]
|
||||
pub struct LogicalInsets {
|
||||
pub struct WindowInsets {
|
||||
pub left: f32,
|
||||
pub top: f32,
|
||||
pub right: f32,
|
||||
@@ -176,14 +193,14 @@ pub struct LogicalInsets {
|
||||
pub ime_bottom: f32,
|
||||
}
|
||||
|
||||
impl LogicalInsets {
|
||||
fn from_physical(insets: Insets, content_scale: f32) -> Self {
|
||||
impl WindowInsets {
|
||||
fn from_physical(insets: Insets) -> Self {
|
||||
Self {
|
||||
left: insets.left as f32 / content_scale,
|
||||
top: insets.top as f32 / content_scale,
|
||||
right: insets.right as f32 / content_scale,
|
||||
bottom: insets.bottom as f32 / content_scale,
|
||||
ime_bottom: insets.ime_bottom as f32 / content_scale,
|
||||
left: insets.left as f32,
|
||||
top: insets.top as f32,
|
||||
right: insets.right as f32,
|
||||
bottom: insets.bottom as f32,
|
||||
ime_bottom: insets.ime_bottom as f32,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -343,10 +360,9 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
|
||||
let ui_state = self.state.android_state();
|
||||
let current_insets = ui_state.insets();
|
||||
if current_insets != ui_state.last_insets {
|
||||
let content_scale = ui_state.content_scale;
|
||||
let logical = LogicalInsets::from_physical(current_insets, content_scale);
|
||||
let physical = WindowInsets::from_physical(current_insets);
|
||||
self.state.android_state_mut().last_insets = current_insets;
|
||||
self.state.on_insets_changed(&mut self.rsc, logical);
|
||||
self.state.on_insets_changed(&mut self.rsc, physical);
|
||||
}
|
||||
|
||||
let ui_state = self.state.android_state();
|
||||
@@ -504,16 +520,10 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
) -> bool {
|
||||
self.drain_tasks();
|
||||
let action = event.action_masked(&mut ctx.env);
|
||||
// Device (physical) pixels, same as every other Android coordinate
|
||||
// -- divided so a touch lands in the same *logical* space layout
|
||||
// now uses (`AndroidUiState::content_scale`'s field comment).
|
||||
// Without this, `window_size()` reporting logical dims while touch
|
||||
// stayed physical would land every tap off by exactly the density
|
||||
// factor on any phone denser than 1x.
|
||||
let ui_state = self.state.android_state();
|
||||
let content_scale = ui_state.content_scale;
|
||||
let x = event.x(&mut ctx.env) / content_scale;
|
||||
let y = event.y(&mut ctx.env) / content_scale;
|
||||
// Device (physical) pixels, same space layout now uses throughout
|
||||
// -- see `AndroidUiState::content_scale`'s field comment.
|
||||
let x = event.x(&mut ctx.env);
|
||||
let y = event.y(&mut ctx.env);
|
||||
let ui_state = self.state.android_state_mut();
|
||||
match action {
|
||||
MotionAction::Down => {
|
||||
@@ -564,7 +574,6 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
height: i32,
|
||||
) {
|
||||
self.drain_tasks();
|
||||
let window = holder.surface(&mut ctx.env).to_native_window(&mut ctx.env);
|
||||
// The layout engine's own notion of the canvas size is separate
|
||||
// from the wgpu surface's -- winit's backend sets it from
|
||||
// `WindowEvent::Resized`, and there is no equivalent automatic
|
||||
@@ -574,29 +583,55 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
// whatever size `UiRenderState::new` starts at instead of the
|
||||
// surface's real one.
|
||||
//
|
||||
// **Logical, not physical** -- `content_scale`'s field comment on
|
||||
// `AndroidUiState`. This call sets `UiRenderState::output_size`,
|
||||
// which is what every widget's absolute `PixelRegion` (a fixed
|
||||
// `.height(56)`, in particular) is computed against; `AndroidRenderer`'s
|
||||
// own `size()`/`resize()`/`new()` already report logical dimensions
|
||||
// to the *shader*'s window uniform, so leaving this call on raw
|
||||
// physical `width`/`height` split the two into different units --
|
||||
// layout placed a "56"-unit-tall row in an ~2219-tall physical
|
||||
// canvas (an absolute, correctly-56-unit box), the shader then
|
||||
// divided that same 56 by a ~845-unit *logical* window dimension,
|
||||
// and the row rendered far too short rather than too tall or
|
||||
// right, because a fixed-size item's absolute unit value never
|
||||
// adapts to the mismatch the way a `rest(n)`-proportional one
|
||||
// does. Found by measuring a fresh install's top button row at
|
||||
// ~40 physical px instead of the ~147px `56 * content_scale`
|
||||
// predicts, immediately after the density fix below was added.
|
||||
let content_scale = self.state.android_state().content_scale;
|
||||
self.render
|
||||
.resize((width as f32 / content_scale, height as f32 / content_scale));
|
||||
// Drop the old renderer (and the surface it owns) before building
|
||||
// one from the new window -- see `AndroidRenderer`'s doc comment.
|
||||
let ui_state = self.state.android_state_mut();
|
||||
ui_state.renderer = None;
|
||||
// **Physical pixels, matching `AndroidRenderer`'s own
|
||||
// `size()`/`resize()`/`new()`** -- `AndroidUiState::content_scale`'s
|
||||
// field comment. This call sets `UiRenderState::output_size`, which
|
||||
// every `rel`/`rest` length resolves against and every `abs`
|
||||
// pixel-region compares to directly; a `dp(56)` height now folds
|
||||
// in the density at `Len::apply_rest` time instead of this call
|
||||
// dividing the whole window into a separate logical space, which
|
||||
// is what used to make every `abs`-unit size (a fixed `.height(56)`
|
||||
// in particular) mean something different from a `rest`-based one.
|
||||
self.render.resize((width as f32, height as f32));
|
||||
|
||||
// **Reuse the existing renderer (device, atlas, buffers, bind
|
||||
// groups) when one is already live -- only reconfigure the
|
||||
// surface.** `surfaceChanged` fires on *every* size or format
|
||||
// change, not only on a genuinely new `Surface`/window: showing
|
||||
// the IME under `adjustResize` resizes the same `SurfaceView` and
|
||||
// is reported through this exact callback. Rebuilding the whole
|
||||
// `AndroidRenderer` here used to mean a fresh `UiRenderNode::new`
|
||||
// -- a brand-new, empty glyph atlas and fresh GPU buffers -- while
|
||||
// `iris_core`'s CPU-side glyph cache (`primitive/text.rs`) kept the
|
||||
// atlas coordinates it had already handed out against the *old*
|
||||
// atlas. Every glyph then drew from a UV rectangle that pointed
|
||||
// into a texture that had just been recreated empty, so text
|
||||
// vanished on the first keyboard open while rects (which never go
|
||||
// through the atlas) kept drawing -- exactly the "rectangles stay,
|
||||
// glyphs disappear" Iris reported. Confirmed by reading this path
|
||||
// end to end (no fresh-atlas rebuild anywhere in `resize()` below,
|
||||
// only in `AndroidRenderer::new`) before changing anything, per
|
||||
// AGENTS.md's "verify before finishing".
|
||||
//
|
||||
// `AndroidRenderer::resize` only reconfigures the wgpu surface and
|
||||
// rewrites the window uniform -- device, atlas, buffers and bind
|
||||
// groups are untouched, so the glyph cache's coordinates stay
|
||||
// valid. A genuinely new surface (after `surface_destroyed`, e.g.
|
||||
// backgrounding) still goes through `AndroidRenderer::new` below,
|
||||
// since `renderer` is `None` in that case.
|
||||
let already_live = self.state.android_state().renderer.is_some();
|
||||
if already_live {
|
||||
let ui_state = self.state.android_state_mut();
|
||||
ui_state
|
||||
.renderer
|
||||
.as_mut()
|
||||
.expect("checked Some above")
|
||||
.resize(width as u32, height as u32);
|
||||
self.render(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
let window = holder.surface(&mut ctx.env).to_native_window(&mut ctx.env);
|
||||
// `AndroidRenderer::new` used to panic here through wgpu's own
|
||||
// default uncaptured-error handler on a bind-group-layout
|
||||
// validation failure -- exactly what aborted the P0 bench APK on
|
||||
@@ -607,6 +642,11 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
|
||||
// the one place in the app that can turn it into something a
|
||||
// person can read, since `ctx.view`/`ctx.env` (needed to reach the
|
||||
// Java side) are only in scope inside a `ViewPeer` callback.
|
||||
//
|
||||
// `content_scale` reaches `AndroidRenderer` only for the
|
||||
// Diagnostics page's report text now -- window size and the
|
||||
// shader's window uniform are physical pixels throughout (see the
|
||||
// `resize` call above), not divided by it.
|
||||
let content_scale = self.state.android_state().content_scale;
|
||||
match AndroidRenderer::new(window, width as u32, height as u32, content_scale) {
|
||||
Ok(renderer) => {
|
||||
@@ -771,15 +811,22 @@ pub fn new_peer<'local, State: AndroidAppState>(
|
||||
state: Default::default(),
|
||||
_state: PhantomData,
|
||||
};
|
||||
// See `TextData::density`'s field doc for why this is set alongside
|
||||
// `render.set_density` below rather than read from there.
|
||||
rsc.ui.text.density = content_scale;
|
||||
let shared = Rc::new(RefCell::new(Shared::default()));
|
||||
let ui_state = AndroidUiState::new(shared.clone(), content_scale);
|
||||
let mut state = State::new(ui_state, &mut rsc);
|
||||
let platform_vm = env.get_java_vm().unwrap();
|
||||
let platform_view = env.new_global_ref(&view.0).unwrap();
|
||||
state.platform_ready(&mut rsc, platform_vm, platform_view);
|
||||
let mut render = UiRenderState::new();
|
||||
// Every `Len::dp` in the tree resolves against this from now on -- see
|
||||
// `UiRenderState::density`'s field doc and `Len::dp`'s.
|
||||
render.set_density(content_scale);
|
||||
let peer = IrisViewPeer {
|
||||
rsc,
|
||||
render: UiRenderState::new(),
|
||||
render,
|
||||
state,
|
||||
task_recv,
|
||||
};
|
||||
|
||||
+427
-4
@@ -1,5 +1,6 @@
|
||||
use crate::prelude::*;
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
ops::{BitOr, Deref, DerefMut},
|
||||
rc::Rc,
|
||||
time::{Duration, Instant},
|
||||
@@ -491,7 +492,25 @@ impl DragArbiter {
|
||||
} else if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
|
||||
self.state = ArbiterState::Panning;
|
||||
self.last = pos;
|
||||
DragOutcome::Pan(dy)
|
||||
// `dy` here is the *whole* drag since `press_start`,
|
||||
// not since the last frame -- nothing panned while
|
||||
// `Undecided` was withholding the slop, so applying it
|
||||
// in full on this one frame is a visible jump the
|
||||
// instant `DRAG_SLOP` is crossed (IRIS_TODO.md's
|
||||
// "scrolling down sometimes jitters the text," root-
|
||||
// caused by tracing `List`'s per-frame offset against
|
||||
// a synthetic monotonic drag: the offset held flat for
|
||||
// every `Undecided` frame, then stepped by several
|
||||
// frames' worth of motion at once on the frame slop
|
||||
// was crossed, before resuming ordinary per-frame
|
||||
// deltas). Only the excess past the slop threshold is
|
||||
// real, undecided motion the reader hasn't seen
|
||||
// reflected yet -- so only that excess is applied now,
|
||||
// the same way Android's own touch handling consumes
|
||||
// `ViewConfiguration.getScaledTouchSlop()` once from
|
||||
// the first scroll past it rather than replaying the
|
||||
// whole pre-threshold drag in one step.
|
||||
DragOutcome::Pan(dy - DRAG_SLOP.copysign(dy))
|
||||
} else if now.duration_since(self.origin_at) >= LONG_PRESS
|
||||
&& dx.abs() <= DRAG_SLOP
|
||||
&& dy.abs() <= DRAG_SLOP
|
||||
@@ -510,6 +529,390 @@ impl DragArbiter {
|
||||
pub fn release(&mut self) {
|
||||
self.state = ArbiterState::Idle;
|
||||
}
|
||||
|
||||
/// Whether the arbiter's current gesture (if any) has committed to
|
||||
/// panning -- what a caller checks at release time to decide whether
|
||||
/// to hand the tracked velocity to [`crate::widget::List::fling`], per
|
||||
/// IRIS_TODO.md's "swiping has no momentum": a fling must only follow
|
||||
/// a pan, never a text selection that happened to end with the finger
|
||||
/// still moving.
|
||||
pub fn is_panning(&self) -> bool {
|
||||
matches!(self.state, ArbiterState::Panning)
|
||||
}
|
||||
}
|
||||
|
||||
/// How far back a [`VelocityTracker`] looks when estimating a fling's
|
||||
/// initial speed -- Android's own `VelocityTracker` defaults to a similar
|
||||
/// short window so a gesture's last flick dominates over its slower start.
|
||||
const VELOCITY_WINDOW: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Tracks a drag's speed along one axis from its last ~100ms of motion, so
|
||||
/// a release can be handed a realistic initial velocity for
|
||||
/// [`AndroidFlingSpline`]/[`FlingCalculator`] rather than a single frame's
|
||||
/// noisy last delta. Fed one timestamped pan delta per frame
|
||||
/// (`add_sample`, the same `dy`/`-dy` quantity `DragArbiter::update`'s
|
||||
/// `Pan` outcome already carries) and answers `velocity()` in units per
|
||||
/// second, matching whatever unit the deltas were in.
|
||||
#[derive(Default)]
|
||||
pub struct VelocityTracker {
|
||||
/// `(when, delta)` pairs, oldest first, trimmed to `VELOCITY_WINDOW`
|
||||
/// on every `add_sample` -- so this never grows past however many
|
||||
/// frames land in that window.
|
||||
samples: VecDeque<(Instant, f32)>,
|
||||
}
|
||||
|
||||
impl VelocityTracker {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Forget everything -- called on a fresh press, so a new gesture's
|
||||
/// velocity is never contaminated by the tail of the previous one.
|
||||
pub fn reset(&mut self) {
|
||||
self.samples.clear();
|
||||
}
|
||||
|
||||
/// Record one frame's motion. `delta` is this frame's movement since
|
||||
/// the last sample, not a cumulative position.
|
||||
pub fn add_sample(&mut self, delta: f32, at: Instant) {
|
||||
self.samples.push_back((at, delta));
|
||||
while let Some(&(when, _)) = self.samples.front() {
|
||||
if at.duration_since(when) > VELOCITY_WINDOW {
|
||||
self.samples.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The estimated speed, in units-per-second, over whatever samples
|
||||
/// currently fall inside the tracking window: total motion divided by
|
||||
/// the elapsed time between the oldest and newest sample still held.
|
||||
/// `0.0` with fewer than two samples (no time span to divide by).
|
||||
pub fn velocity(&self) -> f32 {
|
||||
if self.samples.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let total: f32 = self.samples.iter().map(|&(_, d)| d).sum();
|
||||
let span = self
|
||||
.samples
|
||||
.back()
|
||||
.unwrap()
|
||||
.0
|
||||
.duration_since(self.samples.front().unwrap().0)
|
||||
.as_secs_f32();
|
||||
if span <= 0.0 { 0.0 } else { total / span }
|
||||
}
|
||||
}
|
||||
|
||||
/// Android's fling deceleration curve, ported from AOSP's
|
||||
/// `android.widget.OverScroller.SplineOverScroller` (the same curve
|
||||
/// Compose's `androidx.compose.ui.gestures.AndroidFlingSpline` and
|
||||
/// `androidx.compose.foundation.gestures.FlingCalculator` reuse) so a
|
||||
/// fling here travels the same distance a Compose `LazyColumn`'s own
|
||||
/// `ScrollableDefaults.flingBehavior()` would for the same initial
|
||||
/// velocity -- RUST.md's "Benchmark v2" box asked the two apps' fling
|
||||
/// phase to be comparable, and IRIS_TODO.md's "swiping has no momentum"
|
||||
/// asked for the same physics a reader's muscle memory already expects
|
||||
/// from every other Android scroll view.
|
||||
///
|
||||
/// The curve is a cubic-Bezier-derived spline sampled into two lookup
|
||||
/// tables at start-up (`SPLINE`, built once via [`std::sync::OnceLock`]):
|
||||
/// `SPLINE_POSITION[i]`/`SPLINE_TIME[i]` give the fraction of total
|
||||
/// distance/time elapsed at the `i`th of 100 even steps along the curve's
|
||||
/// own parameter. A lookup at an arbitrary time fraction interpolates
|
||||
/// between the two bracketing samples.
|
||||
mod android_fling_spline {
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const NB_SAMPLES: usize = 100;
|
||||
/// Where the two cubic tension lines cross (AOSP's own constant name
|
||||
/// and value, `SplineOverScroller.INFLEXION`).
|
||||
pub(super) const INFLEXION: f32 = 0.35;
|
||||
const START_TENSION: f32 = 0.5;
|
||||
const END_TENSION: f32 = 1.0;
|
||||
const P1: f32 = START_TENSION * INFLEXION;
|
||||
const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION);
|
||||
|
||||
pub(super) struct Spline {
|
||||
position: [f32; NB_SAMPLES + 1],
|
||||
time: [f32; NB_SAMPLES + 1],
|
||||
}
|
||||
|
||||
fn build() -> Spline {
|
||||
let mut position = [0.0f32; NB_SAMPLES + 1];
|
||||
let mut time = [0.0f32; NB_SAMPLES + 1];
|
||||
let (mut x_min, mut y_min) = (0.0f32, 0.0f32);
|
||||
for i in 0..NB_SAMPLES {
|
||||
let alpha = i as f32 / NB_SAMPLES as f32;
|
||||
|
||||
let mut x_max = 1.0f32;
|
||||
let (mut x, mut coef);
|
||||
loop {
|
||||
x = x_min + (x_max - x_min) / 2.0;
|
||||
coef = 3.0 * x * (1.0 - x);
|
||||
let tx = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x;
|
||||
if (tx - alpha).abs() < 1e-5 {
|
||||
break;
|
||||
}
|
||||
if tx > alpha {
|
||||
x_max = x;
|
||||
} else {
|
||||
x_min = x;
|
||||
}
|
||||
}
|
||||
position[i] = coef * ((1.0 - x) * P1 + x * P2) + x * x * x;
|
||||
|
||||
let mut y_max = 1.0f32;
|
||||
let (mut y, mut coef_y);
|
||||
loop {
|
||||
y = y_min + (y_max - y_min) / 2.0;
|
||||
coef_y = 3.0 * y * (1.0 - y);
|
||||
let dy = coef_y * ((1.0 - y) * START_TENSION + y * END_TENSION) + y * y * y;
|
||||
if (dy - alpha).abs() < 1e-5 {
|
||||
break;
|
||||
}
|
||||
if dy > alpha {
|
||||
y_max = y;
|
||||
} else {
|
||||
y_min = y;
|
||||
}
|
||||
}
|
||||
time[i] = coef_y * ((1.0 - y) * P1 + y * P2) + y * y * y;
|
||||
}
|
||||
position[NB_SAMPLES] = 1.0;
|
||||
time[NB_SAMPLES] = 1.0;
|
||||
Spline { position, time }
|
||||
}
|
||||
|
||||
static SPLINE: OnceLock<Spline> = OnceLock::new();
|
||||
|
||||
/// The fraction of total distance covered at `time_fraction` (0..=1
|
||||
/// of the fling's total duration). Finds the bracketing samples in
|
||||
/// `SPLINE_TIME` and interpolates linearly between their matching
|
||||
/// `SPLINE_POSITION` entries, exactly as AOSP's `SplineOverScroller
|
||||
/// .flingPosition` does.
|
||||
pub(super) fn distance_fraction(time_fraction: f32) -> f32 {
|
||||
let spline = SPLINE.get_or_init(build);
|
||||
let t = time_fraction.clamp(0.0, 1.0);
|
||||
let index = ((t * NB_SAMPLES as f32) as usize).min(NB_SAMPLES - 1);
|
||||
let t_inf = spline.time[index];
|
||||
let t_sup = spline.time[index + 1];
|
||||
let d_inf = spline.position[index];
|
||||
let d_sup = spline.position[index + 1];
|
||||
let span = t_sup - t_inf;
|
||||
if span <= 0.0 {
|
||||
d_inf
|
||||
} else {
|
||||
d_inf + (d_sup - d_inf) * (t - t_inf) / span
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AOSP `SplineOverScroller`'s two other physical constants: the default
|
||||
/// `ViewConfiguration.getScrollFriction()` and the deceleration rate a
|
||||
/// friction of `0.84` per frame at 60Hz corresponds to
|
||||
/// (`ln(0.78)/ln(0.9)`, `SplineOverScroller.DECELERATION_RATE`).
|
||||
const FLING_FRICTION: f32 = 0.015;
|
||||
fn deceleration_rate() -> f32 {
|
||||
(0.78f32.ln()) / (0.9f32.ln())
|
||||
}
|
||||
const GRAVITY_EARTH: f32 = 9.80665;
|
||||
|
||||
/// Turns an initial fling velocity into a total travel distance and
|
||||
/// duration, following AOSP `SplineOverScroller`'s own closed-form
|
||||
/// formulas (`getSplineFlingDistance`/the duration half of `fling()`) --
|
||||
/// ported the same way Compose's `FlingCalculator` is, including its
|
||||
/// `density`-dependent physical coefficient (`computeDeceleration`,
|
||||
/// `GravityEarth * 39.37 * density * 160 * friction`). Density and
|
||||
/// velocity/distance units cancel algebraically as long as velocity and
|
||||
/// the returned distance share one pixel space (physical or logical) --
|
||||
/// [`crate::widget::List::fling`] relies on exactly that cancellation to
|
||||
/// avoid needing a display density of its own, since iris's `List`
|
||||
/// already works in logical (density-independent) pixels throughout.
|
||||
pub struct FlingCalculator {
|
||||
physical_coefficient: f32,
|
||||
}
|
||||
|
||||
impl FlingCalculator {
|
||||
pub fn new(density: f32) -> Self {
|
||||
Self {
|
||||
physical_coefficient: GRAVITY_EARTH * 39.37 * density * 160.0 * FLING_FRICTION,
|
||||
}
|
||||
}
|
||||
|
||||
fn deceleration_for(&self, velocity: f32) -> f32 {
|
||||
(android_fling_spline::INFLEXION * velocity.abs()
|
||||
/ (FLING_FRICTION * self.physical_coefficient))
|
||||
.ln()
|
||||
}
|
||||
|
||||
/// Total signed distance the fling travels before settling, in the
|
||||
/// same pixel units `velocity` was given in.
|
||||
pub fn distance(&self, velocity: f32) -> f32 {
|
||||
if velocity == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let l = self.deceleration_for(velocity);
|
||||
let rate = deceleration_rate();
|
||||
let magnitude =
|
||||
FLING_FRICTION * self.physical_coefficient * (rate / (rate - 1.0) * l).exp();
|
||||
magnitude.copysign(velocity)
|
||||
}
|
||||
|
||||
/// How long the fling takes to settle.
|
||||
pub fn duration(&self, velocity: f32) -> Duration {
|
||||
if velocity == 0.0 {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
let l = self.deceleration_for(velocity);
|
||||
let rate = deceleration_rate();
|
||||
Duration::from_secs_f32((l / (rate - 1.0)).exp())
|
||||
}
|
||||
|
||||
/// The signed distance covered by `elapsed` into a fling of this
|
||||
/// `velocity` that started at `t0` -- what a per-frame ticker
|
||||
/// (`List::tick_fling`) calls to find how far to have scrolled by now.
|
||||
/// Clamped to the full `distance()` once `elapsed` reaches
|
||||
/// `duration()`, so a caller need not special-case "past the end."
|
||||
pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 {
|
||||
let duration = self.duration(velocity);
|
||||
if duration.is_zero() {
|
||||
return 0.0;
|
||||
}
|
||||
let fraction = (elapsed.as_secs_f32() / duration.as_secs_f32()).min(1.0);
|
||||
self.distance(velocity) * android_fling_spline::distance_fraction(fraction)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod velocity_tracker_tests {
|
||||
use super::*;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
// A single fixed base rather than a fresh `Instant::now()` per call --
|
||||
// computing it once per test keeps every sample's spacing exact
|
||||
// instead of at the mercy of however long the test itself takes to
|
||||
// run between calls, the same reasoning `drag_arbiter_tests::t` uses.
|
||||
static BASE: LazyLock<Instant> = LazyLock::new(Instant::now);
|
||||
|
||||
fn t(ms: u64) -> Instant {
|
||||
*BASE + Duration::from_millis(ms)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fewer_than_two_samples_reports_zero() {
|
||||
let mut v = VelocityTracker::new();
|
||||
assert_eq!(v.velocity(), 0.0);
|
||||
v.add_sample(10.0, t(0));
|
||||
assert_eq!(v.velocity(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_steady_drag_reports_its_own_speed() {
|
||||
// 5px every 10ms, 11 samples spanning 100ms, sums to 55px over
|
||||
// 0.1s -- 550px/s by this tracker's own "sum of deltas over the
|
||||
// span between the oldest and newest held sample" definition.
|
||||
let mut v = VelocityTracker::new();
|
||||
for i in 0..=10 {
|
||||
v.add_sample(5.0, t(i * 10));
|
||||
}
|
||||
assert!((v.velocity() - 550.0).abs() < 1.0, "got {}", v.velocity());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_last_100ms_of_samples_count() {
|
||||
// An old, fast burst well outside the window followed by a slow,
|
||||
// steady drag should report the recent speed, not the average of
|
||||
// both -- otherwise a flick that trails off would still fling at
|
||||
// its earlier, faster speed. The burst sits 110ms before the last
|
||||
// sample, just past the 100ms window, so it is evicted.
|
||||
let mut v = VelocityTracker::new();
|
||||
v.add_sample(1000.0, t(0)); // will be 110ms old by the last sample
|
||||
for i in 1..=11 {
|
||||
v.add_sample(1.0, t(i * 10)); // 1px/10ms = 100px/s
|
||||
}
|
||||
assert!(
|
||||
(v.velocity() - 110.0).abs() < 5.0,
|
||||
"old burst leaked into the window: got {}",
|
||||
v.velocity()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_forgets_prior_samples() {
|
||||
let mut v = VelocityTracker::new();
|
||||
v.add_sample(500.0, t(0));
|
||||
v.add_sample(500.0, t(10));
|
||||
assert!(v.velocity() != 0.0);
|
||||
v.reset();
|
||||
assert_eq!(v.velocity(), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fling_calculator_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zero_velocity_flings_nowhere() {
|
||||
let calc = FlingCalculator::new(1.0);
|
||||
assert_eq!(calc.distance(0.0), 0.0);
|
||||
assert_eq!(calc.duration(0.0), Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_grows_with_velocity_and_keeps_its_sign() {
|
||||
let calc = FlingCalculator::new(2.75); // a typical phone's density
|
||||
let d_slow = calc.distance(2000.0);
|
||||
let d_fast = calc.distance(12000.0);
|
||||
assert!(d_slow > 0.0);
|
||||
assert!(d_fast > d_slow);
|
||||
assert_eq!(calc.distance(-12000.0), -d_fast);
|
||||
}
|
||||
|
||||
/// Summing the spline's own per-frame position deltas across the
|
||||
/// whole fling has to land within 1% of the closed-form `distance()`
|
||||
/// -- this is the guarantee that `List::tick_fling`'s per-frame reads
|
||||
/// of `position_at` actually add up to the total the fling promised,
|
||||
/// not merely that the two formulas look plausible independently.
|
||||
#[test]
|
||||
fn integrating_position_at_matches_the_closed_form_distance() {
|
||||
let calc = FlingCalculator::new(1.0);
|
||||
for velocity in [1500.0f32, 5000.0, 12000.0, -12000.0] {
|
||||
let total = calc.distance(velocity);
|
||||
let duration = calc.duration(velocity);
|
||||
let final_position = calc.position_at(velocity, duration);
|
||||
let err = (final_position - total).abs() / total.abs();
|
||||
assert!(
|
||||
err < 0.01,
|
||||
"velocity {velocity}: position_at(duration)={final_position} vs distance()={total}, err={err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_at_is_monotonic_and_clamped_past_the_end() {
|
||||
let calc = FlingCalculator::new(1.0);
|
||||
let velocity = 12000.0f32;
|
||||
let duration = calc.duration(velocity);
|
||||
let total = calc.distance(velocity);
|
||||
let mut last = 0.0;
|
||||
let mut t = Duration::ZERO;
|
||||
while t < duration {
|
||||
let p = calc.position_at(velocity, t);
|
||||
assert!(p >= last - 0.01, "position went backwards at {t:?}");
|
||||
last = p;
|
||||
t += Duration::from_millis(16);
|
||||
}
|
||||
// Well past the end, it stays pinned at the total -- a caller
|
||||
// must be able to ask "where would this fling be" without first
|
||||
// checking whether it has already settled.
|
||||
assert_eq!(
|
||||
calc.position_at(velocity, duration + Duration::from_secs(5)),
|
||||
total
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -534,9 +937,13 @@ mod drag_arbiter_tests {
|
||||
fn a_vertical_drag_pans_immediately() {
|
||||
let mut a = DragArbiter::new();
|
||||
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||
// The transition frame applies only the motion past `DRAG_SLOP`
|
||||
// (20 - 8 = 12), not the full 20px since `press_start` -- see the
|
||||
// `Pan` arm's own comment for why replaying the whole withheld
|
||||
// drag in one step is the scroll-jitter bug this guards against.
|
||||
assert_eq!(
|
||||
a.update(Vec2::new(0.0, 20.0), t(10)),
|
||||
DragOutcome::Pan(20.0)
|
||||
DragOutcome::Pan(12.0)
|
||||
);
|
||||
// Subsequent frames keep panning, by the delta since last frame.
|
||||
assert_eq!(
|
||||
@@ -545,6 +952,22 @@ mod drag_arbiter_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Direct regression test for the fix: a slow drag that crosses
|
||||
/// `DRAG_SLOP` by only a fraction of a pixel must not still produce a
|
||||
/// visible jump -- the amount applied on the crossing frame should
|
||||
/// itself shrink toward zero as the crossing gets closer to exactly
|
||||
/// `DRAG_SLOP`, rather than always dumping the whole pre-threshold
|
||||
/// distance at once.
|
||||
#[test]
|
||||
fn crossing_the_slop_by_a_little_pans_by_a_little() {
|
||||
let mut a = DragArbiter::new();
|
||||
a.press_start(Vec2::new(0.0, 0.0), t(0), false);
|
||||
assert_eq!(
|
||||
a.update(Vec2::new(0.0, DRAG_SLOP + 0.5), t(10)),
|
||||
DragOutcome::Pan(0.5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_horizontal_drag_with_nothing_selected_does_not_select() {
|
||||
let mut a = DragArbiter::new();
|
||||
@@ -603,7 +1026,7 @@ mod drag_arbiter_tests {
|
||||
a.press_start(Vec2::new(0.0, 0.0), t(0), true);
|
||||
assert_eq!(
|
||||
a.update(Vec2::new(0.0, 20.0), t(10)),
|
||||
DragOutcome::Pan(20.0)
|
||||
DragOutcome::Pan(12.0)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -646,7 +1069,7 @@ mod drag_arbiter_tests {
|
||||
a.press_start(Vec2::new(0.0, 700.0), t(0), false);
|
||||
assert_eq!(
|
||||
a.update(Vec2::new(0.0, 720.0), t(10)),
|
||||
DragOutcome::Pan(20.0)
|
||||
DragOutcome::Pan(12.0)
|
||||
);
|
||||
assert!(!a.is_idle());
|
||||
}
|
||||
|
||||
+287
-8
@@ -102,7 +102,7 @@
|
||||
|
||||
use crate::prelude::*;
|
||||
use iris_core::util::HashMap;
|
||||
use std::collections::VecDeque;
|
||||
use std::{collections::VecDeque, sync::Arc, time::Instant};
|
||||
|
||||
/// A stable identifier for a loaded row, reused across pages so that a row
|
||||
/// already measured and drawn is not treated as new when data is inserted
|
||||
@@ -213,6 +213,39 @@ pub struct List {
|
||||
/// row is evicted (`pop_front`/`pop_back`) so this cannot grow past
|
||||
/// however many rows are currently loaded.
|
||||
heights: HashMap<RowKey, f32>,
|
||||
/// A fling in progress, or `None` if the list is at rest -- see
|
||||
/// `fling`/`tick_fling`/`is_scrolling`, IRIS_TODO.md's "swiping has no
|
||||
/// momentum."
|
||||
fling: Option<Fling>,
|
||||
/// What `tick_fling` re-arms every frame a fling is still running, so
|
||||
/// the list keeps animating without needing a caller to poll it --
|
||||
/// set once via `set_redraw_handle` by whoever owns the surface this
|
||||
/// list draws into (the same handle `iris::task::Tasks::redraw_handle`
|
||||
/// hands out elsewhere). `None` for a list that never flings
|
||||
/// (headless tests, a caller driving `tick_fling` by hand as
|
||||
/// `bench_client.rs`'s scripted phases do).
|
||||
redraw: Option<Arc<dyn RequestRedraw>>,
|
||||
/// Whether the last `draw` found no more content above the topmost
|
||||
/// visible row (its top edge at or past the viewport's own top, with
|
||||
/// no `prev_slot`) -- what `tick_fling` clamps a fling moving toward
|
||||
/// the start against. Stale (from whatever the last draw found) on a
|
||||
/// list that hasn't drawn yet; `false` by default, matching "assume
|
||||
/// there is more content until a draw proves otherwise."
|
||||
at_start: bool,
|
||||
/// The mirror of `at_start` for the newest end.
|
||||
at_end: bool,
|
||||
}
|
||||
|
||||
/// One in-flight fling: the physics answer (`FlingCalculator`) plus how
|
||||
/// much of its total distance has already been applied to the anchor, so
|
||||
/// `tick_fling` only ever moves the list by this frame's *incremental*
|
||||
/// delta -- matching every other place in this widget that scrolls by
|
||||
/// writing `anchor.offset`.
|
||||
struct Fling {
|
||||
calc: FlingCalculator,
|
||||
velocity: f32,
|
||||
started_at: Instant,
|
||||
applied: f32,
|
||||
}
|
||||
|
||||
impl List {
|
||||
@@ -226,6 +259,10 @@ impl List {
|
||||
snap_end: true,
|
||||
viewport_len: 0.0,
|
||||
last_viewport_len: 0.0,
|
||||
fling: None,
|
||||
redraw: None,
|
||||
at_start: false,
|
||||
at_end: false,
|
||||
pending_tap: None,
|
||||
extents: HashMap::default(),
|
||||
heights: HashMap::default(),
|
||||
@@ -361,6 +398,97 @@ impl List {
|
||||
}
|
||||
}
|
||||
|
||||
/// Give this list a way to ask for another frame on its own, so a
|
||||
/// fling keeps animating without a caller polling it every tick --
|
||||
/// see the `redraw` field's doc. Pass the same handle
|
||||
/// `iris::task::Tasks::redraw_handle` hands a `spawn`ed task; a list
|
||||
/// that never calls this can still `fling`, but has to be driven by a
|
||||
/// caller-owned loop instead (`bench_client.rs`'s scripted phases do
|
||||
/// exactly that, since they need to await settling rather than let it
|
||||
/// run in the background).
|
||||
pub fn set_redraw_handle(&mut self, handle: Arc<dyn RequestRedraw>) {
|
||||
self.redraw = Some(handle);
|
||||
}
|
||||
|
||||
/// Start a fling at `velocity_px_per_s` (this widget's own pixel
|
||||
/// space, same sign convention as `scroll`'s `amt`: positive continues
|
||||
/// moving later content into view). Cancels any fling already in
|
||||
/// progress. A caller with a live touch/press must cancel this on the
|
||||
/// next touch-down (`cancel_fling`) -- `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.
|
||||
///
|
||||
/// Density cancels out of the underlying spline as long as velocity
|
||||
/// and the distance it produces share one pixel space (see
|
||||
/// `FlingCalculator`'s own doc) -- `List` works entirely in logical
|
||||
/// pixels, so `1.0` here is not a placeholder for "unknown density,"
|
||||
/// it is the correct density for a self-consistent unit system.
|
||||
pub fn fling(&mut self, velocity_px_per_s: f32) {
|
||||
if velocity_px_per_s == 0.0 || self.anchor.is_none() {
|
||||
self.fling = None;
|
||||
return;
|
||||
}
|
||||
self.fling = Some(Fling {
|
||||
calc: FlingCalculator::new(1.0),
|
||||
velocity: velocity_px_per_s,
|
||||
started_at: Instant::now(),
|
||||
applied: 0.0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether a fling is currently animating. What a caller's own
|
||||
/// per-frame loop polls to know when to stop driving `tick_fling`
|
||||
/// (`bench_client.rs`'s fling phase) or to decide whether the list is
|
||||
/// "moving on its own" for any other purpose.
|
||||
pub fn is_scrolling(&self) -> bool {
|
||||
self.fling.is_some()
|
||||
}
|
||||
|
||||
/// Cancel any fling in progress with no further movement -- the next
|
||||
/// touch-down's job, per `fling`'s own doc.
|
||||
pub fn cancel_fling(&mut self) {
|
||||
self.fling = None;
|
||||
}
|
||||
|
||||
/// Advance an in-flight fling to `now`, applying this call's share of
|
||||
/// its total travel via `scroll` and re-arming this list's own redraw
|
||||
/// handle (if it has one) for another frame. Returns whether the
|
||||
/// fling is still going after this call -- `false` either because it
|
||||
/// settled on its own spline-decided schedule or because it reached
|
||||
/// `at_start`/`at_end` (the module doc's clamp: a fling must not carry
|
||||
/// the list past content that does not exist, unlike an ordinary
|
||||
/// touch-pan, which this widget already leaves unclamped by design).
|
||||
///
|
||||
/// Safe to call even with no fling active (a no-op returning `false`),
|
||||
/// so a caller does not need to check `is_scrolling` first.
|
||||
pub fn tick_fling(&mut self, now: Instant) -> bool {
|
||||
let Some(f) = &mut self.fling else {
|
||||
return false;
|
||||
};
|
||||
let elapsed = now.saturating_duration_since(f.started_at);
|
||||
let target = f.calc.position_at(f.velocity, elapsed);
|
||||
let delta = target - f.applied;
|
||||
f.applied = target;
|
||||
let settled_on_schedule = elapsed >= f.calc.duration(f.velocity);
|
||||
let velocity = f.velocity;
|
||||
self.scroll(delta);
|
||||
|
||||
// Clamp: a fling moving toward the start that has already reached
|
||||
// it (or one moving toward the end that has already reached that)
|
||||
// stops rather than continuing to spend its remaining distance on
|
||||
// a part of the list that will never scroll further.
|
||||
let hit_bound = (velocity < 0.0 && self.at_start) || (velocity > 0.0 && self.at_end);
|
||||
|
||||
if settled_on_schedule || hit_bound {
|
||||
self.fling = None;
|
||||
return false;
|
||||
}
|
||||
if let Some(redraw) = &self.redraw {
|
||||
redraw.request_redraw();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Snap to the newest content (last item, or the `more_after`
|
||||
/// sentinel if set), bottom-aligned to the viewport. O(1).
|
||||
pub fn jump_to_end(&mut self) {
|
||||
@@ -606,9 +734,10 @@ impl List {
|
||||
let axis = self.axis;
|
||||
let output_len = painter.output_size().axis(axis);
|
||||
let container_len = painter.region().axis(axis).len();
|
||||
let density = painter.density();
|
||||
let resolve = move |used: Size| -> f32 {
|
||||
used.axis(axis)
|
||||
.apply_rest()
|
||||
.apply_rest(density)
|
||||
.within_len(container_len)
|
||||
.to_abs(output_len)
|
||||
};
|
||||
@@ -724,26 +853,33 @@ impl Widget for List {
|
||||
};
|
||||
let (mut top, mut bottom) = self.place(painter, anchor.slot, placement);
|
||||
|
||||
let mut idx = anchor.slot;
|
||||
let mut idx_top = anchor.slot;
|
||||
while top > 0.0 {
|
||||
let Some(prev) = self.prev_slot(idx) else {
|
||||
let Some(prev) = self.prev_slot(idx_top) else {
|
||||
break;
|
||||
};
|
||||
let (t, _) = self.place(painter, prev, Placement::Bottom(top));
|
||||
top = t;
|
||||
idx = prev;
|
||||
idx_top = prev;
|
||||
}
|
||||
|
||||
idx = anchor.slot;
|
||||
let mut idx_bottom = anchor.slot;
|
||||
while bottom < self.viewport_len {
|
||||
let Some(next) = self.next_slot(idx) else {
|
||||
let Some(next) = self.next_slot(idx_bottom) else {
|
||||
break;
|
||||
};
|
||||
let (_, b) = self.place(painter, next, Placement::Top(bottom));
|
||||
bottom = b;
|
||||
idx = next;
|
||||
idx_bottom = next;
|
||||
}
|
||||
|
||||
// What `tick_fling` clamps a fling against -- see `at_start`'s
|
||||
// field doc. `top`/`bottom` are the extreme edges actually placed
|
||||
// this frame, and `prev_slot`/`next_slot` returning `None` is what
|
||||
// "no more content" means everywhere else in this widget.
|
||||
self.at_start = self.prev_slot(idx_top).is_none() && top >= 0.0;
|
||||
self.at_end = self.next_slot(idx_bottom).is_none() && bottom <= self.viewport_len;
|
||||
|
||||
self.update_snap_end();
|
||||
Size::REST
|
||||
}
|
||||
@@ -1177,4 +1313,147 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enough rows, tall enough, that a fling toward the start has real
|
||||
/// room to travel before `at_start` clamps it -- shared by the fling
|
||||
/// tests below.
|
||||
fn build_flingable_list(rsc: &mut TestRsc) -> (WeakWidget<List>, StrongWidget, UiRenderState) {
|
||||
let mut list = List::new(Axis::Y);
|
||||
push_rows(rsc, &mut list, &(0..200).collect::<Vec<_>>(), 20.0);
|
||||
let (list_weak, root) = add_list(rsc, list);
|
||||
let mut render = UiRenderState::new();
|
||||
render.resize((100.0, 600.0));
|
||||
render.update(&root, rsc);
|
||||
(list_weak, root, render)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fling_moves_the_list_and_then_settles() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||
|
||||
// A fling toward the start: negative velocity, matching `scroll`'s
|
||||
// sign convention (`Selection::drag` calls `scroll(-dy)` for a
|
||||
// downward finger motion revealing older content).
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
|
||||
assert!(rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||
|
||||
let start = Instant::now();
|
||||
let mut last_still_scrolling = true;
|
||||
for step in 0..600 {
|
||||
let now = start + std::time::Duration::from_millis(step * 16);
|
||||
last_still_scrolling = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
||||
render.update(&root, &mut rsc);
|
||||
if !last_still_scrolling {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!last_still_scrolling,
|
||||
"fling never settled within 600 steps"
|
||||
);
|
||||
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fling_distance_is_positive_toward_the_end() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||
// Start scrolled away from the newest end so there is room for an
|
||||
// end-ward fling to actually move.
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start();
|
||||
render.update(&root, &mut rsc);
|
||||
let before = rsc.ui.widgets.get(&list_weak).unwrap().extents[&0];
|
||||
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(8000.0);
|
||||
let start = Instant::now();
|
||||
for step in 0..600 {
|
||||
let now = start + std::time::Duration::from_millis(step * 16);
|
||||
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
||||
render.update(&root, &mut rsc);
|
||||
if !still {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
// Row 0 either scrolled out of the loaded extents (flung well past
|
||||
// it) or moved upward (smaller top) -- either way, real motion
|
||||
// happened toward the end rather than staying put.
|
||||
if let Some(after) = list_ref.extents.get(&0) {
|
||||
assert!(
|
||||
after.top < before.top,
|
||||
"fling toward the end did not move content up"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_fling_stops_it_with_no_further_movement() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-8000.0);
|
||||
let start = Instant::now();
|
||||
rsc.ui
|
||||
.widgets
|
||||
.get_mut(&list_weak)
|
||||
.unwrap()
|
||||
.tick_fling(start + std::time::Duration::from_millis(16));
|
||||
render.update(&root, &mut rsc);
|
||||
assert!(rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().cancel_fling();
|
||||
assert!(!rsc.ui.widgets.get(&list_weak).unwrap().is_scrolling());
|
||||
|
||||
let before = rsc.ui.widgets.get(&list_weak).unwrap().extents[&199];
|
||||
// A tick after cancelling must be a no-op -- this is what a fresh
|
||||
// touch-down relies on to stop a fling in its tracks.
|
||||
let still = rsc
|
||||
.ui
|
||||
.widgets
|
||||
.get_mut(&list_weak)
|
||||
.unwrap()
|
||||
.tick_fling(start + std::time::Duration::from_millis(200));
|
||||
render.update(&root, &mut rsc);
|
||||
assert!(!still);
|
||||
let after = rsc.ui.widgets.get(&list_weak).unwrap().extents[&199];
|
||||
assert_eq!((before.top, before.bottom), (after.top, after.bottom));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fling_toward_the_start_stops_at_the_first_row() {
|
||||
let mut rsc = TestRsc {
|
||||
ui: UiData::default(),
|
||||
};
|
||||
let (list_weak, root, mut render) = build_flingable_list(&mut rsc);
|
||||
// An enormous velocity that would travel far past all 200 rows if
|
||||
// unclamped -- this is exactly what IRIS_TODO.md's "way faster...
|
||||
// better for stress testing" fling asks for.
|
||||
rsc.ui.widgets.get_mut(&list_weak).unwrap().fling(-50_000.0);
|
||||
let start = Instant::now();
|
||||
for step in 0..2000 {
|
||||
let now = start + std::time::Duration::from_millis(step * 16);
|
||||
let still = rsc.ui.widgets.get_mut(&list_weak).unwrap().tick_fling(now);
|
||||
render.update(&root, &mut rsc);
|
||||
if !still {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let list_ref = rsc.ui.widgets.get(&list_weak).unwrap();
|
||||
assert!(
|
||||
list_ref.at_start,
|
||||
"fling should have clamped at the first row"
|
||||
);
|
||||
let first = list_ref.extents[&0];
|
||||
assert!(
|
||||
first.top >= -0.5,
|
||||
"clamped fling overshot the first row's top: {}",
|
||||
first.top
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,14 +17,15 @@ impl Widget for Aligned {
|
||||
// already-resolved region double-applies that composition and is
|
||||
// wrong for any widget nested below the root.
|
||||
let used = painter.widget(&self.inner);
|
||||
let density = painter.density();
|
||||
let region = match self.align.tuple() {
|
||||
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }),
|
||||
(Some(x), Some(y)) => used.to_uivec2(density).align(RegionAlign { x, y }),
|
||||
(Some(x), None) => {
|
||||
let x = used.x.apply_rest().align(x);
|
||||
let x = used.x.apply_rest(density).align(x);
|
||||
UiRegion::new(x, UiSpan::FULL)
|
||||
}
|
||||
(None, Some(y)) => {
|
||||
let y = used.y.apply_rest().align(y);
|
||||
let y = used.y.apply_rest(density).align(y);
|
||||
UiRegion::new(UiSpan::FULL, y)
|
||||
}
|
||||
(None, None) => UiRegion::FULL,
|
||||
|
||||
@@ -9,12 +9,12 @@ pub struct MaxSize {
|
||||
impl MaxSize {
|
||||
/// Caps a reported length at `max`, comparing in pixels since `Len`'s
|
||||
/// rel/abs/rest components are not otherwise comparable.
|
||||
fn clamp(len: Len, max: Option<Len>, output: f32) -> Len {
|
||||
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().to_abs(output);
|
||||
let max_px = max.apply_rest().to_abs(output);
|
||||
let len_px = len.apply_rest(density).to_abs(output);
|
||||
let max_px = max.apply_rest(density).to_abs(output);
|
||||
if len_px > max_px { max } else { len }
|
||||
}
|
||||
|
||||
@@ -24,11 +24,11 @@ impl MaxSize {
|
||||
/// 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) -> UiSpan {
|
||||
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();
|
||||
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)
|
||||
@@ -41,15 +41,16 @@ impl MaxSize {
|
||||
impl Widget for MaxSize {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
let output = painter.output_size();
|
||||
let density = painter.density();
|
||||
let offered = painter.px_size();
|
||||
let region = UiRegion {
|
||||
x: Self::clamp_region(offered.x, self.x, output.x),
|
||||
y: Self::clamp_region(offered.y, self.y, output.y),
|
||||
x: Self::clamp_region(offered.x, self.x, output.x, density),
|
||||
y: Self::clamp_region(offered.y, self.y, output.y, density),
|
||||
};
|
||||
let used = painter.widget_within(&self.inner, region);
|
||||
Size {
|
||||
x: Self::clamp(used.x, self.x, output.x),
|
||||
y: Self::clamp(used.y, self.y, output.y),
|
||||
x: Self::clamp(used.x, self.x, output.x, density),
|
||||
y: Self::clamp(used.y, self.y, output.y, density),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,12 @@ pub struct Pad {
|
||||
|
||||
impl Widget for Pad {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
let used = painter.widget_within(&self.inner, self.padding.region());
|
||||
let width = self.padding.left + self.padding.right;
|
||||
let height = self.padding.top + self.padding.bottom;
|
||||
let density = painter.density();
|
||||
let used = painter.widget_within(&self.inner, self.padding.region(density));
|
||||
let width =
|
||||
self.padding.left.apply_rest(density).abs + self.padding.right.apply_rest(density).abs;
|
||||
let height =
|
||||
self.padding.top.apply_rest(density).abs + self.padding.bottom.apply_rest(density).abs;
|
||||
Size {
|
||||
x: used.x + Len::abs(width),
|
||||
y: used.y + Len::abs(height),
|
||||
@@ -17,23 +20,29 @@ impl Widget for Pad {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each side is a `Len`, not a bare `f32`, so `.pad(dp(10))` resolves
|
||||
/// against the display's density the same way any other size does -- see
|
||||
/// `Len::dp`'s field doc. `.pad(10)` (a bare number) still works via
|
||||
/// `From<T: UiNum>` below, unchanged: it becomes an `abs` (physical-pixel)
|
||||
/// `Len`, exactly as a bare number always has meant elsewhere in this
|
||||
/// crate.
|
||||
pub struct Padding {
|
||||
pub left: f32,
|
||||
pub right: f32,
|
||||
pub top: f32,
|
||||
pub bottom: f32,
|
||||
pub left: Len,
|
||||
pub right: Len,
|
||||
pub top: Len,
|
||||
pub bottom: Len,
|
||||
}
|
||||
|
||||
impl Padding {
|
||||
pub const ZERO: Self = Self {
|
||||
left: 0.0,
|
||||
right: 0.0,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
left: Len::ZERO,
|
||||
right: Len::ZERO,
|
||||
top: Len::ZERO,
|
||||
bottom: Len::ZERO,
|
||||
};
|
||||
|
||||
pub fn uniform(amt: impl UiNum) -> Self {
|
||||
let amt = amt.to_f32();
|
||||
pub fn uniform(amt: impl Into<Len>) -> Self {
|
||||
let amt = amt.into();
|
||||
Self {
|
||||
left: amt,
|
||||
right: amt,
|
||||
@@ -41,80 +50,84 @@ impl Padding {
|
||||
bottom: amt,
|
||||
}
|
||||
}
|
||||
pub fn region(&self) -> UiRegion {
|
||||
pub fn region(&self, density: f32) -> UiRegion {
|
||||
let mut region = UiRegion::FULL;
|
||||
region.x.start.abs += self.left;
|
||||
region.y.start.abs += self.top;
|
||||
region.x.end.abs -= self.right;
|
||||
region.y.end.abs -= self.bottom;
|
||||
region.x.start.abs += self.left.apply_rest(density).abs;
|
||||
region.y.start.abs += self.top.apply_rest(density).abs;
|
||||
region.x.end.abs -= self.right.apply_rest(density).abs;
|
||||
region.y.end.abs -= self.bottom.apply_rest(density).abs;
|
||||
region
|
||||
}
|
||||
pub fn x(amt: impl UiNum) -> Self {
|
||||
let amt = amt.to_f32();
|
||||
pub fn x(amt: impl Into<Len>) -> Self {
|
||||
let amt = amt.into();
|
||||
Self {
|
||||
left: amt,
|
||||
right: amt,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
top: Len::ZERO,
|
||||
bottom: Len::ZERO,
|
||||
}
|
||||
}
|
||||
pub fn y(amt: impl UiNum) -> Self {
|
||||
let amt = amt.to_f32();
|
||||
pub fn y(amt: impl Into<Len>) -> Self {
|
||||
let amt = amt.into();
|
||||
Self {
|
||||
left: 0.0,
|
||||
right: 0.0,
|
||||
left: Len::ZERO,
|
||||
right: Len::ZERO,
|
||||
top: amt,
|
||||
bottom: amt,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn top(amt: impl UiNum) -> Self {
|
||||
pub fn top(amt: impl Into<Len>) -> Self {
|
||||
let mut s = Self::ZERO;
|
||||
s.top = amt.to_f32();
|
||||
s.top = amt.into();
|
||||
s
|
||||
}
|
||||
|
||||
pub fn bottom(amt: impl UiNum) -> Self {
|
||||
pub fn bottom(amt: impl Into<Len>) -> Self {
|
||||
let mut s = Self::ZERO;
|
||||
s.bottom = amt.to_f32();
|
||||
s.bottom = amt.into();
|
||||
s
|
||||
}
|
||||
|
||||
pub fn left(amt: impl UiNum) -> Self {
|
||||
pub fn left(amt: impl Into<Len>) -> Self {
|
||||
let mut s = Self::ZERO;
|
||||
s.left = amt.to_f32();
|
||||
s.left = amt.into();
|
||||
s
|
||||
}
|
||||
|
||||
pub fn right(amt: impl UiNum) -> Self {
|
||||
pub fn right(amt: impl Into<Len>) -> Self {
|
||||
let mut s = Self::ZERO;
|
||||
s.right = amt.to_f32();
|
||||
s.right = amt.into();
|
||||
s
|
||||
}
|
||||
|
||||
pub fn with_top(mut self, amt: impl UiNum) -> Self {
|
||||
self.top = amt.to_f32();
|
||||
pub fn with_top(mut self, amt: impl Into<Len>) -> Self {
|
||||
self.top = amt.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_bottom(mut self, amt: impl UiNum) -> Self {
|
||||
self.bottom = amt.to_f32();
|
||||
pub fn with_bottom(mut self, amt: impl Into<Len>) -> Self {
|
||||
self.bottom = amt.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_left(mut self, amt: impl UiNum) -> Self {
|
||||
self.left = amt.to_f32();
|
||||
pub fn with_left(mut self, amt: impl Into<Len>) -> Self {
|
||||
self.left = amt.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_right(mut self, amt: impl UiNum) -> Self {
|
||||
self.right = amt.to_f32();
|
||||
pub fn with_right(mut self, amt: impl Into<Len>) -> Self {
|
||||
self.right = amt.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: UiNum> From<T> for Padding {
|
||||
/// Covers both a bare number (`.pad(8)`, via `Len`'s own `From<N: UiNum>`
|
||||
/// blanket -- an `abs`/physical-pixel `Len`) and a `Len` directly
|
||||
/// (`.pad(dp(10))`) with the one impl, since `Len: Into<Len>` is the
|
||||
/// reflexive case of the same bound.
|
||||
impl<T: Into<Len>> From<T> for Padding {
|
||||
fn from(amt: T) -> Self {
|
||||
Self::uniform(amt.to_f32())
|
||||
Self::uniform(amt.into())
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ impl Widget for Scroll {
|
||||
|
||||
self.content_len = used
|
||||
.axis(axis)
|
||||
.apply_rest()
|
||||
.apply_rest(painter.density())
|
||||
.within_len(container_len)
|
||||
.to_abs(output_len);
|
||||
|
||||
|
||||
@@ -17,12 +17,13 @@ impl Widget for Sized {
|
||||
// learn its size, then moves it into place with a pure
|
||||
// translation; that translation is only valid if what got painted
|
||||
// is already the reported size, anchored the same way both times.
|
||||
let density = painter.density();
|
||||
let mut region = UiRegion::FULL;
|
||||
if let Some(x) = self.x {
|
||||
region.x = x.apply_rest().align(AxisAlign::Neg);
|
||||
region.x = x.apply_rest(density).align(AxisAlign::Neg);
|
||||
}
|
||||
if let Some(y) = self.y {
|
||||
region.y = y.apply_rest().align(AxisAlign::Neg);
|
||||
region.y = y.apply_rest(density).align(AxisAlign::Neg);
|
||||
}
|
||||
let used = painter.widget_within(&self.inner, region);
|
||||
Size {
|
||||
|
||||
@@ -4,12 +4,18 @@ use std::marker::PhantomData;
|
||||
pub struct Span {
|
||||
pub children: Vec<StrongWidget>,
|
||||
pub dir: Dir,
|
||||
pub gap: f32,
|
||||
/// A `Len` (not a bare `f32`) so `dp(4)` resolves against the display's
|
||||
/// density the same way any other size in the tree does -- see
|
||||
/// `Len::dp`'s field doc. Only the `abs` component (folded from `dp` at
|
||||
/// draw time, `Widget::draw` below) is meaningful here; `rel`/`rest`
|
||||
/// were never supported for a gap and still are not.
|
||||
pub gap: Len,
|
||||
}
|
||||
|
||||
impl Widget for Span {
|
||||
fn draw(&mut self, painter: &mut Painter) -> Size {
|
||||
let axis = self.dir.axis;
|
||||
let gap = self.gap.apply_rest(painter.density()).abs;
|
||||
|
||||
// Phase 1: draw each child once, at the ambient (unmodified, full)
|
||||
// region a size-only query used to see before this migration, to
|
||||
@@ -25,7 +31,7 @@ impl Widget for Span {
|
||||
.map(|child| painter.widget(child).axis(axis))
|
||||
.collect();
|
||||
|
||||
let gap_total = self.gap * self.children.len().saturating_sub(1) as f32;
|
||||
let gap_total = gap * self.children.len().saturating_sub(1) as f32;
|
||||
let total = lens.iter().fold(Len::abs(gap_total), |s, &l| s + l);
|
||||
|
||||
// Phase 2: place each child for real, using the lengths just
|
||||
@@ -54,7 +60,7 @@ impl Widget for Span {
|
||||
child_region.flip(axis);
|
||||
}
|
||||
let used = painter.widget_within(child, child_region);
|
||||
start.abs += self.gap;
|
||||
start.abs += gap;
|
||||
|
||||
let ortho = used.axis(!axis);
|
||||
if ortho.rel > 0.0 || ortho.rest > 0.0 {
|
||||
@@ -82,12 +88,12 @@ impl Span {
|
||||
Self {
|
||||
children: Vec::new(),
|
||||
dir,
|
||||
gap: 0.0,
|
||||
gap: Len::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gap(mut self, gap: impl UiNum) -> Self {
|
||||
self.gap = gap.to_f32();
|
||||
pub fn gap(mut self, gap: impl Into<Len>) -> Self {
|
||||
self.gap = gap.into();
|
||||
self
|
||||
}
|
||||
|
||||
@@ -103,7 +109,7 @@ impl Span {
|
||||
pub struct SpanBuilder<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag> {
|
||||
pub children: Wa,
|
||||
pub dir: Dir,
|
||||
pub gap: f32,
|
||||
pub gap: Len,
|
||||
_pd: PhantomData<(State, Tag)>,
|
||||
}
|
||||
|
||||
@@ -129,13 +135,13 @@ impl<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag>
|
||||
Self {
|
||||
children,
|
||||
dir,
|
||||
gap: 0.0,
|
||||
gap: Len::ZERO,
|
||||
_pd: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gap(mut self, gap: impl UiNum) -> Self {
|
||||
self.gap = gap.to_f32();
|
||||
pub fn gap(mut self, gap: impl Into<Len>) -> Self {
|
||||
self.gap = gap.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,8 @@ impl<'a> TextEditCtx<'a> {
|
||||
fn layout(&mut self) -> &Layout<UiColor> {
|
||||
let attrs = self.text.view.attrs.clone();
|
||||
let width = self.text.view.wrap_width();
|
||||
self.text.view.buf.shape(self.data, &attrs, width);
|
||||
let density = self.data.density;
|
||||
self.text.view.buf.shape(self.data, &attrs, width, density);
|
||||
self.text.view.buf.layout()
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ where
|
||||
.label("Message")
|
||||
.add(rsc);
|
||||
|
||||
let bar: WeakWidget = (field.pad(12).width(rest(1)),)
|
||||
let bar: WeakWidget = (field.pad(dp(12)).width(rest(1)),)
|
||||
.span(Dir::RIGHT)
|
||||
.background(rect(UiColor::new(40, 40, 46, 255)))
|
||||
.add(rsc);
|
||||
|
||||
@@ -174,8 +174,8 @@ where
|
||||
|
||||
(header, field.width(rest(1)))
|
||||
.span(Dir::DOWN)
|
||||
.gap(4)
|
||||
.pad(10)
|
||||
.gap(dp(4))
|
||||
.pad(dp(10))
|
||||
.add_strong(rsc)
|
||||
.any()
|
||||
}
|
||||
|
||||
@@ -41,6 +41,12 @@ pub struct Selection {
|
||||
/// pan wanting the same touch gesture). See `drag` below, and
|
||||
/// `iris::sense::DragArbiter`'s own doc for the decision itself.
|
||||
arbiter: DragArbiter,
|
||||
/// Tracks the last ~100ms of this gesture's pan deltas (in the same
|
||||
/// signed units `list.scroll` takes), so a release that turns out to
|
||||
/// have been panning can hand `List::fling` a realistic initial
|
||||
/// velocity instead of one frame's noisy last delta --
|
||||
/// IRIS_TODO.md's "swiping has no momentum."
|
||||
velocity: VelocityTracker,
|
||||
}
|
||||
|
||||
impl Default for Selection {
|
||||
@@ -55,6 +61,7 @@ impl Selection {
|
||||
rows: BTreeMap::new(),
|
||||
anchor: None,
|
||||
arbiter: DragArbiter::new(),
|
||||
velocity: VelocityTracker::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,9 +185,21 @@ impl Selection {
|
||||
CursorSense::PressStart(_) => {
|
||||
let already_selected = self.has_selection(ui);
|
||||
self.arbiter.press_start(pos_window, now, already_selected);
|
||||
self.velocity.reset();
|
||||
// A fresh touch-down cancels any fling still coasting from
|
||||
// the previous gesture -- `List::fling`'s own doc, and
|
||||
// Android's `Scroller::abortAnimation` for the same reason.
|
||||
list(ui).cancel_fling();
|
||||
self.arbiter.update(pos_window, now)
|
||||
}
|
||||
CursorSense::PressEnd(_) => {
|
||||
// A fling only ever follows a pan -- never a selection
|
||||
// that happened to end with the finger still moving, and
|
||||
// never a tap/long-press that never left `Undecided`.
|
||||
if self.arbiter.is_panning() {
|
||||
let v = self.velocity.velocity();
|
||||
list(ui).fling(v);
|
||||
}
|
||||
self.arbiter.release();
|
||||
return;
|
||||
}
|
||||
@@ -198,13 +217,19 @@ impl Selection {
|
||||
_ if self.arbiter.is_idle() => {
|
||||
let already_selected = self.has_selection(ui);
|
||||
self.arbiter.press_start(pos_window, now, already_selected);
|
||||
self.velocity.reset();
|
||||
list(ui).cancel_fling();
|
||||
self.arbiter.update(pos_window, now)
|
||||
}
|
||||
_ => self.arbiter.update(pos_window, now),
|
||||
};
|
||||
match outcome {
|
||||
DragOutcome::Undecided => {}
|
||||
DragOutcome::Pan(dy) => list(ui).scroll(-dy),
|
||||
DragOutcome::Pan(dy) => {
|
||||
let amt = -dy;
|
||||
self.velocity.add_sample(amt, now);
|
||||
list(ui).scroll(amt);
|
||||
}
|
||||
DragOutcome::SelectStart => {
|
||||
// Grep-able on "iris selection" the way the frame report is
|
||||
// on "iris frame report" -- selection has no accessibility
|
||||
|
||||
Reference in new issue
Block a user