Merge remote-tracking branch 'origin/rustify' into worktree-agent-a16b22e34539b810e

# Conflicts:
#	iris/android-app/src/bench_client.rs
#	iris/android-app/src/bench_jni.rs
This commit is contained in:
iris committed 2026-09-06 01:05:18 -04:00
commit 4afc453faa
25 files changed
+837 -204

No files matched your search

+43
View File
@@ -583,3 +583,46 @@ inset bugs the emulator never showed).
Explicit `Arc`-backed value passed to the callback and kept on Explicit `Arc`-backed value passed to the callback and kept on
`AndroidRenderer`, not a global — a caller wanting one on desktop builds `AndroidRenderer`, not a global — a caller wanting one on desktop builds
its own the same way. 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.
+13 -2
View File
@@ -421,8 +421,19 @@ do not duplicate it there.
## Build (asked for by Iris, 2026-09-06): a density-independent length unit ## Build (asked for by Iris, 2026-09-06): a density-independent length unit
- [ ] **A third length kind beside relative and pixels, so display scales - [x] **A third length kind beside relative and pixels, so display scales
"just work".** Iris's words: "another length type similar to absolute & "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 relative, so instead there would be relative, pixels, and another unit
like em or whatever is standard. That way different display scales like em or whatever is standard. That way different display scales
should just work." Today a length is either a fraction of the parent should just work." Today a length is either a fraction of the parent
+52
View File
@@ -861,6 +861,58 @@ unspecified rather than getting them wrong:
conditions, so the remaining slack was accepted rather than chased conditions, so the remaining slack was accepted rather than chased
further. 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 ## For IRIS.md
When this lands, copy this entry into `IRIS.md` (newest first): When this lands, copy this entry into `IRIS.md` (newest first):
+123
View File
@@ -4438,6 +4438,129 @@ device.
apk/release/iris-bench-arm64.apk`; that repo's own README gained a apk/release/iris-bench-arm64.apk`; that repo's own README gained a
dated entry. Still not confirmed on Iris's actual phone. 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 **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 -- phone" items this box's own text names as follow-ups are fixed --
`List::fling`/`VelocityTracker`/`FlingCalculator` (IRIS.md's `List::fling`/`VelocityTracker`/`FlingCalculator` (IRIS.md's
@@ -1,8 +1,15 @@
package dev.iris.android.demo; package dev.iris.android.demo;
import android.app.Activity; import android.app.Activity;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context; import android.content.Context;
import android.view.Gravity; 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.ScrollView;
import android.widget.TextView; import android.widget.TextView;
@@ -68,4 +75,81 @@ public final class IrisView extends RustView {
scroll.addView(text); scroll.addView(text);
activity.setContentView(scroll); 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));
});
}
} }
+114 -19
View File
@@ -114,10 +114,17 @@ pub struct BenchClient {
running: bool, running: bool,
/// The keyboard phase's own confirmation channel -- updated from /// The keyboard phase's own confirmation channel -- updated from
/// `on_insets_changed` (the platform's own answer for whether the IME /// `on_insets_changed` (the platform's own answer for whether the IME
/// is actually visible, per `LogicalInsets::ime_bottom`), read from the /// is actually visible, per `WindowInsets::ime_bottom`), read from the
/// benchmark's spawned task via the shared `Arc<Mutex<_>>` rather than /// benchmark's spawned task via the shared `Arc<Mutex<_>>` rather than
/// `ctx.update`, since neither side needs the widget tree for this. /// `ctx.update`, since neither side needs the widget tree for this.
ime_state: Arc<Mutex<ImeState>>, ime_state: Arc<Mutex<ImeState>>,
/// 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,
} }
/// See `BenchClient::ime_state`'s doc. `shown_events`/`hidden_events` /// See `BenchClient::ime_state`'s doc. `shown_events`/`hidden_events`
@@ -241,7 +248,7 @@ impl AndroidAppState for BenchClient {
let tree = ( let tree = (
top_bar, top_bar,
content.height(rest(2)), content.height(rest(2)),
report_display.height(rest(1)).pad(8), report_display.height(rest(1)).pad(dp(8)),
) )
.span(Dir::DOWN) .span(Dir::DOWN)
.add_strong(rsc) .add_strong(rsc)
@@ -277,6 +284,7 @@ impl AndroidAppState for BenchClient {
last_report: None, last_report: None,
running: false, running: false,
ime_state: Arc::new(Mutex::new(ImeState::default())), ime_state: Arc::new(Mutex::new(ImeState::default())),
keyboard_was_visible: false,
}; };
let (backlog, stream_tail) = parse_fixture(); let (backlog, stream_tail) = parse_fixture();
@@ -305,38 +313,93 @@ impl AndroidAppState for BenchClient {
/// field comment. Rebuilds the row rather than mutating a stored /// field comment. Rebuilds the row rather than mutating a stored
/// `Padding` in place, since nothing here holds a handle to one. /// `Padding` in place, since nothing here holds a handle to one.
/// ///
/// Also the keyboard phase's own confirmation signal: `ime_bottom` /// Also two things downstream of the same `ime_bottom` transition:
/// crossing 0<->positive is the platform's own answer for whether the /// **the keyboard phase's own confirmation signal** (`ime_state`'s
/// IME actually opened or closed, fired from the real insets /// doc -- the platform's own answer for whether the IME actually
/// callback rather than assumed from having called `show_ime`/ /// opened or closed, rather than assumed from having called
/// `hide_ime` -- see `ime_state`'s doc. /// `show_ime`/`hide_ime`), and **the trigger for the keyboard
/// diagnostics capture** (RUST.md's P0 box): the IME resizing the
/// surface is exactly the case a 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( fn on_insets_changed(
&mut self, &mut self,
rsc: &mut AndroidRsc<Self>, rsc: &mut AndroidRsc<Self>,
insets: iris::android::LogicalInsets, insets: iris::android::WindowInsets,
) { ) {
let controls = bench_controls(rsc, insets.top); let controls = bench_controls(rsc, insets.top);
(self.top_bar)(rsc).set(controls); (self.top_bar)(rsc).set(controls);
let ime_visible = insets.ime_bottom > 0.0;
let mut ime = self.ime_state.lock().unwrap(); let mut ime = self.ime_state.lock().unwrap();
let now_visible = insets.ime_bottom > 0.0; if ime_visible && !ime.visible {
if now_visible && !ime.visible {
ime.shown_events += 1; ime.shown_events += 1;
} }
if !now_visible && ime.visible { if !ime_visible && ime.visible {
ime.hidden_events += 1; ime.hidden_events += 1;
} }
ime.visible = now_visible; ime.visible = ime_visible;
drop(ime);
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>; 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 /// `on_insets_changed` has run once) -- folded in here, rather than
/// exposing the unadded builder for a caller to `.pad()` itself, because /// 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 /// naming that builder's type at each call site is more machinery than a
/// top-of-screen padding number is worth. /// 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 { fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
let run_rect = rect(Color::rgb(40, 70, 40)) let run_rect = rect(Color::rgb(40, 70, 40))
.on( .on(
@@ -351,7 +414,7 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
wtext("Run benchmark").size(18).text_align(Align::CENTER), wtext("Run benchmark").size(18).text_align(Align::CENTER),
) )
.stack() .stack()
.pad(8) .pad(dp(8))
.add(rsc); .add(rsc);
let copy_rect = rect(Color::rgb(50, 50, 60)) let copy_rect = rect(Color::rgb(50, 50, 60))
@@ -367,7 +430,7 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
wtext("Copy report").size(18).text_align(Align::CENTER), wtext("Copy report").size(18).text_align(Align::CENTER),
) )
.stack() .stack()
.pad(8) .pad(dp(8))
.add(rsc); .add(rsc);
let diag_rect = rect(Color::rgb(60, 45, 70)) let diag_rect = rect(Color::rgb(60, 45, 70))
@@ -383,12 +446,14 @@ fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
wtext("Diagnostics").size(18).text_align(Align::CENTER), wtext("Diagnostics").size(18).text_align(Align::CENTER),
) )
.stack() .stack()
.pad(8) .pad(dp(8))
.add(rsc); .add(rsc);
(run, copy, diagnostics) let buttons = (run, copy, diagnostics).span(Dir::RIGHT).add(rsc);
.span(Dir::RIGHT)
.height(56) (rect(HEADER_SURFACE), buttons)
.stack()
.height(dp(56))
.pad(Padding::top(top_pad)) .pad(Padding::top(top_pad))
.add_strong(rsc) .add_strong(rsc)
.any() .any()
@@ -428,6 +493,36 @@ impl BenchClient {
self.last_report = Some(report); 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) { fn copy_report(&mut self) {
let Some(report) = &self.last_report else { let Some(report) = &self.last_report else {
log::info!("iris bench report: nothing to copy -- run the benchmark first"); log::info!("iris bench report: nothing to copy -- run the benchmark first");
+27
View File
@@ -222,4 +222,31 @@ impl PlatformHandle {
.ok() .ok()
} }
} }
/// 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(())
}
} }
+64 -7
View File
@@ -9,7 +9,31 @@ pub struct Size {
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub struct Len { pub struct Len {
/// Physical pixels -- a raw device pixel, unaffected by the display's
/// density. Rare to want directly (a hairline border is the usual
/// case); most sizes should be `dp` instead. See `dp`'s own doc for why
/// the two are kept separate rather than one field a caller has to
/// remember to pre-multiply.
pub abs: f32, pub abs: f32,
/// Density-independent pixels -- Android's `dp` / CSS's reference pixel
/// (1 unit = 1/160in), resolved against the display's density at
/// layout time (`apply_rest`'s `density` parameter) rather than at the
/// point a widget is built, since density is a property of the device
/// this ends up running on, not of the widget tree. This is the unit
/// IRIS_TODO.md's "a density-independent length unit" item asked for,
/// 2026-09-06: before it existed, every size in the tree was `abs`
/// (physical pixels), and the only way to make a 16px design draw at
/// the right *size* on a denser display was a single global multiply
/// applied to the whole rendered scene after layout -- which is also
/// what made text blurry (RUST.md's P0 box, "blurry ... glyphs drawn
/// at logical size and stretched by the scale"): a glyph rasterised at
/// 16 physical px and then stretched 3x by that global multiply is a
/// 48px area sampled from a 16px bitmap. Resolving `dp` per-length at
/// layout time instead means the font size handed to the text shaper
/// is already the physical size (`16.0.dp() * 3.0`), so the glyph
/// atlas rasterises at the display's real resolution and nothing
/// downstream needs to stretch anything.
pub dp: f32,
pub rel: f32, pub rel: f32,
pub rest: f32, pub rest: f32,
} }
@@ -67,10 +91,10 @@ impl Size {
} }
} }
pub fn to_uivec2(self) -> UiVec2 { pub fn to_uivec2(self, density: f32) -> UiVec2 {
UiVec2 { UiVec2 {
x: self.x.apply_rest(), x: self.x.apply_rest(density),
y: self.y.apply_rest(), y: self.y.apply_rest(density),
} }
} }
@@ -98,26 +122,43 @@ impl Size {
impl Len { impl Len {
pub const ZERO: Self = Self { pub const ZERO: Self = Self {
abs: 0.0, abs: 0.0,
dp: 0.0,
rel: 0.0, rel: 0.0,
rest: 0.0, rest: 0.0,
}; };
pub const REST: Self = Self { pub const REST: Self = Self {
abs: 0.0, abs: 0.0,
dp: 0.0,
rel: 0.0, rel: 0.0,
rest: 1.0, rest: 1.0,
}; };
pub fn apply_rest(&self) -> UiScalar { /// Resolves to a `UiScalar`, folding `dp` into `abs` pixels against
/// `density` (physical pixels per dp -- 1.0 on a desktop or an
/// unscaled display, `content_scale` on Android; see `dp`'s field
/// doc). Every other component of `Len` is already resolution-
/// independent (`rel` is a fraction of the parent; `rest` becomes a
/// fraction too, below), so `density` only ever touches this one term.
pub fn apply_rest(&self, density: f32) -> UiScalar {
UiScalar { UiScalar {
rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 }, rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 },
abs: self.abs, abs: self.abs + self.dp * density,
} }
} }
pub fn abs(abs: impl UiNum) -> Self { pub fn abs(abs: impl UiNum) -> Self {
Self { Self {
abs: abs.to_f32(), abs: abs.to_f32(),
dp: 0.0,
rel: 0.0,
rest: 0.0,
}
}
pub fn dp(dp: impl UiNum) -> Self {
Self {
abs: 0.0,
dp: dp.to_f32(),
rel: 0.0, rel: 0.0,
rest: 0.0, rest: 0.0,
} }
@@ -125,6 +166,7 @@ impl Len {
pub fn rel(rel: impl UiNum) -> Self { pub fn rel(rel: impl UiNum) -> Self {
Self { Self {
abs: 0.0, abs: 0.0,
dp: 0.0,
rel: rel.to_f32(), rel: rel.to_f32(),
rest: 0.0, rest: 0.0,
} }
@@ -132,6 +174,7 @@ impl Len {
pub fn rest(ratio: impl UiNum) -> Self { pub fn rest(ratio: impl UiNum) -> Self {
Self { Self {
abs: 0.0, abs: 0.0,
dp: 0.0,
rel: 0.0, rel: 0.0,
rest: ratio.to_f32(), rest: ratio.to_f32(),
} }
@@ -144,6 +187,15 @@ pub mod len_fns {
pub fn abs(abs: impl UiNum) -> Len { pub fn abs(abs: impl UiNum) -> Len {
Len { Len {
abs: abs.to_f32(), abs: abs.to_f32(),
dp: 0.0,
rel: 0.0,
rest: 0.0,
}
}
pub fn dp(dp: impl UiNum) -> Len {
Len {
abs: 0.0,
dp: dp.to_f32(),
rel: 0.0, rel: 0.0,
rest: 0.0, rest: 0.0,
} }
@@ -151,6 +203,7 @@ pub mod len_fns {
pub fn rel(rel: impl UiNum) -> Len { pub fn rel(rel: impl UiNum) -> Len {
Len { Len {
abs: 0.0, abs: 0.0,
dp: 0.0,
rel: rel.to_f32(), rel: rel.to_f32(),
rest: 0.0, rest: 0.0,
} }
@@ -158,14 +211,15 @@ pub mod len_fns {
pub fn rest(ratio: impl UiNum) -> Len { pub fn rest(ratio: impl UiNum) -> Len {
Len { Len {
abs: 0.0, abs: 0.0,
dp: 0.0,
rel: 0.0, rel: 0.0,
rest: ratio.to_f32(), rest: ratio.to_f32(),
} }
} }
} }
impl_op!(Len Add add; abs rel rest); impl_op!(Len Add add; abs dp rel rest);
impl_op!(Len Sub sub; abs rel rest); impl_op!(Len Sub sub; abs dp rel rest);
impl_op!(Size Add add; x y); impl_op!(Size Add add; x y);
impl_op!(Size Sub sub; x y); impl_op!(Size Sub sub; x y);
@@ -187,6 +241,9 @@ impl std::fmt::Display for Len {
if self.abs != 0.0 { if self.abs != 0.0 {
write!(f, "{} abs;", self.abs)?; write!(f, "{} abs;", self.abs)?;
} }
if self.dp != 0.0 {
write!(f, "{} dp;", self.dp)?;
}
if self.rel != 0.0 { if self.rel != 0.0 {
write!(f, "{} rel;", self.rel)?; write!(f, "{} rel;", self.rel)?;
} }
+46 -10
View File
@@ -66,6 +66,17 @@ pub struct TextData {
pub layout_cx: LayoutContext<UiColor>, pub layout_cx: LayoutContext<UiColor>,
scale_cx: ScaleContext, scale_cx: ScaleContext,
pub atlas: GlyphAtlas, 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 { impl Default for TextData {
@@ -75,6 +86,7 @@ impl Default for TextData {
layout_cx: LayoutContext::new(), layout_cx: LayoutContext::new(),
scale_cx: ScaleContext::new(), scale_cx: ScaleContext::new(),
atlas: GlyphAtlas::default(), atlas: GlyphAtlas::default(),
density: 1.0,
}; };
data.register_bundled_fonts(); data.register_bundled_fonts();
data data
@@ -363,7 +375,7 @@ pub struct TextBuffer {
/// `set_spans` forces `shaped` to `None` directly, the same way `edit` /// `set_spans` forces `shaped` to `None` directly, the same way `edit`
/// does, since spans change far less often than a naive equality check /// does, since spans change far less often than a naive equality check
/// on the whole `Vec` would cost to compute every frame. /// on the whole `Vec` would cost to compute every frame.
shaped: Option<(TextAttrs, Option<f32>)>, shaped: Option<(TextAttrs, Option<f32>, f32)>,
} }
impl TextBuffer { impl TextBuffer {
@@ -419,19 +431,42 @@ impl TextBuffer {
Vec2::new(self.layout.width(), self.layout.height()) Vec2::new(self.layout.width(), self.layout.height())
} }
/// Lay the text out, unless it is already laid out for these attributes and /// Lay the text out, unless it is already laid out for these
/// this width. /// attributes, this width and this density.
pub fn shape(&mut self, data: &mut TextData, attrs: &TextAttrs, width: Option<f32>) { ///
if self.shaped.as_ref() == Some(&(attrs.clone(), width)) { /// **`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; return;
} }
let mut builder = data let mut builder = data
.layout_cx .layout_cx
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true); .ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
builder.push_default(StyleProperty::FontFamily(attrs.family.family())); 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( builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
attrs.line_height, attrs.line_height * density,
))); )));
builder.push_default(StyleProperty::Brush(attrs.color)); builder.push_default(StyleProperty::Brush(attrs.color));
for span in &self.spans { for span in &self.spans {
@@ -443,7 +478,7 @@ impl TextBuffer {
builder.push(StyleProperty::FontFamily(family.family()), range.clone()); builder.push(StyleProperty::FontFamily(family.family()), range.clone());
} }
if let Some(size) = span.font_size { 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 { if span.bold {
builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone()); builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone());
@@ -459,7 +494,7 @@ impl TextBuffer {
self.layout.break_all_lines(width); self.layout.break_all_lines(width);
self.layout self.layout
.align(Alignment::Start, AlignmentOptions::default()); .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, attrs: &TextAttrs,
width: Option<f32>, width: Option<f32>,
textures: &mut Textures, textures: &mut Textures,
density: f32,
) -> RenderedText { ) -> RenderedText {
buffer.shape(self, attrs, width); buffer.shape(self, attrs, width, density);
let glyphs = self.place(buffer, textures); let glyphs = self.place(buffer, textures);
RenderedText { RenderedText {
glyphs: std::sync::Arc::new(glyphs), glyphs: std::sync::Arc::new(glyphs),
+9 -1
View File
@@ -165,8 +165,10 @@ impl<'a> Painter<'a> {
attrs: &TextAttrs, attrs: &TextAttrs,
width: Option<f32>, width: Option<f32>,
) -> RenderedText { ) -> RenderedText {
let density = self.state.density;
let ui = self.rsc.ui_mut(); 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. /// 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 self.state.output_size
} }
/// Physical pixels per `dp` -- see `UiRenderState::density`'s field
/// doc. What `Len::dp`'s `apply_rest` call resolves against.
pub fn density(&self) -> f32 {
self.state.density
}
pub fn px_size(&mut self) -> Vec2 { pub fn px_size(&mut self) -> Vec2 {
self.region.size().to_abs(self.state.output_size) self.region.size().to_abs(self.state.output_size)
} }
+22 -1
View File
@@ -9,6 +9,12 @@ pub struct UiRenderState {
pub active: HashMap<WidgetId, ActiveData>, pub active: HashMap<WidgetId, ActiveData>,
pub layers: PrimitiveLayers, pub layers: PrimitiveLayers,
pub(super) output_size: Vec2, 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>, old_root: Option<WidgetId>,
resized: bool, resized: bool,
@@ -35,6 +41,7 @@ impl UiRenderState {
active: Default::default(), active: Default::default(),
layers: Default::default(), layers: Default::default(),
output_size: Vec2::ZERO, output_size: Vec2::ZERO,
density: 1.0,
old_root: None, old_root: None,
resized: false, resized: false,
draw_started: Default::default(), draw_started: Default::default(),
@@ -60,6 +67,20 @@ impl UiRenderState {
self.resized = true; 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) { 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 // safety mechanism for memory leaks; might wanna return a result instead so user can
// decide whether to panic or not // decide whether to panic or not
@@ -311,7 +332,7 @@ impl UiRenderState {
}; };
let from = active let from = active
.size .size
.to_uivec2() .to_uivec2(self.density)
.align(RegionAlign::TOP_LEFT) .align(RegionAlign::TOP_LEFT)
.within(&active.region); .within(&active.region);
let slot = active.move_slot; let slot = active.move_slot;
+5 -2
View File
@@ -68,12 +68,15 @@ fn build_row<Rsc: UiRsc + 'static>(rsc: &mut Rsc, i: usize) -> StrongWidget {
let mut span = Span::empty(Dir::DOWN); let mut span = Span::empty(Dir::DOWN);
span.push(text); span.push(text);
span.push(img); 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 { } else {
wtext(row_text(i)) wtext(row_text(i))
.wrap(true) .wrap(true)
.color(text_color) .color(text_color)
.pad(8.0) .pad(dp(8.0))
.background(rect(tint)) .background(rect(tint))
.add_strong(rsc) .add_strong(rsc)
.any() .any()
+1 -1
View File
@@ -23,7 +23,7 @@ mod view;
pub use insets::Insets; pub use insets::Insets;
pub use render::AndroidRenderer; pub use render::AndroidRenderer;
pub use view::{ pub use view::{
AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState, IrisViewPeer, LogicalInsets, AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState, IrisViewPeer, WindowInsets,
new_peer, new_peer,
}; };
+21 -19
View File
@@ -208,14 +208,14 @@ impl AndroidRenderer {
surface.configure(&device, &config); surface.configure(&device, &config);
let encoder = Self::create_encoder(&device); let encoder = Self::create_encoder(&device);
// Logical size (physical / `content_scale`) -- see // Physical pixels, matching the swapchain's own `width`/`height`
// `android::view::AndroidUiState::content_scale`'s field comment // exactly -- see `android::view::AndroidUiState::content_scale`'s
// for why this crate now divides at all (RUST.md's P0 box, "text // field comment for why this is no longer divided into a separate
// is far too small"). The swapchain above stays at the real // logical space (that stopgap is what made text blurry, RUST.md's
// physical `width`/`height` for a sharp framebuffer. // P0 box). `Len::dp` folds the density in at layout time instead,
let logical_size = // so nothing here needs to know it at all.
iris_core::util::Vec2::new(width as f32 / content_scale, height as f32 / content_scale); let window_size = iris_core::util::Vec2::new(width as f32, height as f32);
let ui = match UiRenderNode::new(&device, &queue, &config, logical_size) { let ui = match UiRenderNode::new(&device, &queue, &config, window_size) {
Ok(ui) => ui, Ok(ui) => ui,
Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)), Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)),
}; };
@@ -398,25 +398,27 @@ impl AndroidRenderer {
submit_start.elapsed() submit_start.elapsed()
} }
/// Logical size (physical / `content_scale`) -- the unit layout and /// Physical pixels -- the unit layout and hit-testing use, matching
/// hit-testing use, matching the window uniform's own units. See /// the window uniform's own units. See
/// `android::view::AndroidUiState::content_scale`'s field comment. /// `android::view::AndroidUiState::content_scale`'s field comment.
pub fn size(&self) -> iris_core::util::Vec2 { pub fn size(&self) -> iris_core::util::Vec2 {
iris_core::util::Vec2::new( iris_core::util::Vec2::new(self.config.width as f32, self.config.height as f32)
self.config.width as f32 / self.content_scale,
self.config.height as f32 / self.content_scale,
)
} }
/// 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) { pub fn resize(&mut self, width: u32, height: u32) {
self.config.width = width; self.config.width = width;
self.config.height = height; self.config.height = height;
self.surface.configure(&self.device, &self.config); self.surface.configure(&self.device, &self.config);
let logical = iris_core::util::Vec2::new( let size = iris_core::util::Vec2::new(width as f32, height as f32);
width as f32 / self.content_scale, self.ui.resize(size, &self.queue);
height as f32 / self.content_scale,
);
self.ui.resize(logical, &self.queue);
} }
} }
+115 -68
View File
@@ -70,19 +70,31 @@ pub struct AndroidUiState {
/// because `dumpsys gfxinfo` cannot see a `SurfaceView`'s own /// because `dumpsys gfxinfo` cannot see a `SurfaceView`'s own
/// GPU-drawn frames at all. See `iris_core::FrameReport`'s own doc. /// GPU-drawn frames at all. See `iris_core::FrameReport`'s own doc.
pub frame_report: FrameReport, pub frame_report: FrameReport,
/// `DisplayMetrics.density` (`new_peer`'s doc comment), read once at /// `DisplayMetrics.density` (`new_peer`'s doc comment): physical pixels
/// view construction: physical pixels per dp on this device. Neither /// per dp on this device, read once at view construction and carried
/// this crate nor `default::` had ever divided by it before RUST.md's /// on `UiRenderState::density` (`render.set_density`, `new_peer`) from
/// P0 box's phone report ("text is far too small") -- `window_size` /// then on -- every `Len::dp` in the widget tree resolves against it at
/// below and `surface_changed`'s call into `UiRenderState::resize` both /// layout time (`Len::dp`'s field doc, IRIS_TODO.md's
/// report *logical* (physical / `content_scale`) dimensions now, which /// "density-independent length unit" item, 2026-09-06).
/// 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 /// **Everything else in this module is physical pixels, matching the
/// stays at the real physical resolution (`AndroidRenderer`'s own /// real wgpu surface/swapchain resolution** -- window size, touch
/// `config.width/height`) for a sharp framebuffer; only the *logical* /// coordinates, insets. That is a correction from an earlier version
/// coordinate system layout, hit-testing and the window uniform agree /// of this comment, which had `window_size`/`surface_changed`'s
/// on is scaled. Touch coordinates (`on_touch_event`) are divided by /// `UiRenderState::resize` call divide by `content_scale` into a
/// this too, so they land in the same space layout is using. /// *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, pub content_scale: f32,
/// The last insets `render()` saw -- compared each frame so /// The last insets `render()` saw -- compared each frame so
/// `AndroidAppState::on_insets_changed` fires only when they actually /// `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 /// (RUST.md's P0 box: "the status-bar inset is not applied" reported
/// the two top buttons sitting under it, because nothing read `.top` /// the two top buttons sitting under it, because nothing read `.top`
/// at all), and again on a rotation or the keyboard opening/closing. /// at all), and again on a rotation or the keyboard opening/closing.
/// `insets` is in the same *logical* units `content_scale` converts /// `insets` is in the same physical-pixel units everything else in the
/// everything else to (physical / `content_scale`), so a widget can add /// tree now uses (`AndroidUiState::content_scale`'s field comment), so
/// it to a layout size directly. The default does nothing -- most /// a widget can add it to a layout size directly -- `dp(...) +
/// screens have no chrome that sits under a system bar. /// 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)] #[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 /// `insets::Insets` as `f32`, for the widget-facing callback above -- a
/// `AndroidUiState::content_scale`'s field comment. A distinct type from /// distinct type from `insets::Insets` so a caller of `on_insets_changed`
/// `insets::Insets` (rather than dividing in place) so a reader at the call /// is not coupled to that module's own (`i32`, JNI-shaped) representation.
/// site can tell which unit a value is already in without checking where it /// Both are physical pixels; this used to divide by `content_scale` into a
/// came from. /// 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)] #[derive(Clone, Copy, Default, Debug, PartialEq)]
pub struct LogicalInsets { pub struct WindowInsets {
pub left: f32, pub left: f32,
pub top: f32, pub top: f32,
pub right: f32, pub right: f32,
@@ -176,14 +193,14 @@ pub struct LogicalInsets {
pub ime_bottom: f32, pub ime_bottom: f32,
} }
impl LogicalInsets { impl WindowInsets {
fn from_physical(insets: Insets, content_scale: f32) -> Self { fn from_physical(insets: Insets) -> Self {
Self { Self {
left: insets.left as f32 / content_scale, left: insets.left as f32,
top: insets.top as f32 / content_scale, top: insets.top as f32,
right: insets.right as f32 / content_scale, right: insets.right as f32,
bottom: insets.bottom as f32 / content_scale, bottom: insets.bottom as f32,
ime_bottom: insets.ime_bottom as f32 / content_scale, ime_bottom: insets.ime_bottom as f32,
} }
} }
} }
@@ -343,10 +360,9 @@ impl<State: AndroidAppState> IrisViewPeer<State> {
let ui_state = self.state.android_state(); let ui_state = self.state.android_state();
let current_insets = ui_state.insets(); let current_insets = ui_state.insets();
if current_insets != ui_state.last_insets { if current_insets != ui_state.last_insets {
let content_scale = ui_state.content_scale; let physical = WindowInsets::from_physical(current_insets);
let logical = LogicalInsets::from_physical(current_insets, content_scale);
self.state.android_state_mut().last_insets = 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(); let ui_state = self.state.android_state();
@@ -504,16 +520,10 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
) -> bool { ) -> bool {
self.drain_tasks(); self.drain_tasks();
let action = event.action_masked(&mut ctx.env); let action = event.action_masked(&mut ctx.env);
// Device (physical) pixels, same as every other Android coordinate // Device (physical) pixels, same space layout now uses throughout
// -- divided so a touch lands in the same *logical* space layout // -- see `AndroidUiState::content_scale`'s field comment.
// now uses (`AndroidUiState::content_scale`'s field comment). let x = event.x(&mut ctx.env);
// Without this, `window_size()` reporting logical dims while touch let y = event.y(&mut ctx.env);
// 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;
let ui_state = self.state.android_state_mut(); let ui_state = self.state.android_state_mut();
match action { match action {
MotionAction::Down => { MotionAction::Down => {
@@ -564,7 +574,6 @@ impl<State: AndroidAppState> ViewPeer for IrisViewPeer<State> {
height: i32, height: i32,
) { ) {
self.drain_tasks(); 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 // The layout engine's own notion of the canvas size is separate
// from the wgpu surface's -- winit's backend sets it from // from the wgpu surface's -- winit's backend sets it from
// `WindowEvent::Resized`, and there is no equivalent automatic // `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 // whatever size `UiRenderState::new` starts at instead of the
// surface's real one. // surface's real one.
// //
// **Logical, not physical** -- `content_scale`'s field comment on // **Physical pixels, matching `AndroidRenderer`'s own
// `AndroidUiState`. This call sets `UiRenderState::output_size`, // `size()`/`resize()`/`new()`** -- `AndroidUiState::content_scale`'s
// which is what every widget's absolute `PixelRegion` (a fixed // field comment. This call sets `UiRenderState::output_size`, which
// `.height(56)`, in particular) is computed against; `AndroidRenderer`'s // every `rel`/`rest` length resolves against and every `abs`
// own `size()`/`resize()`/`new()` already report logical dimensions // pixel-region compares to directly; a `dp(56)` height now folds
// to the *shader*'s window uniform, so leaving this call on raw // in the density at `Len::apply_rest` time instead of this call
// physical `width`/`height` split the two into different units -- // dividing the whole window into a separate logical space, which
// layout placed a "56"-unit-tall row in an ~2219-tall physical // is what used to make every `abs`-unit size (a fixed `.height(56)`
// canvas (an absolute, correctly-56-unit box), the shader then // in particular) mean something different from a `rest`-based one.
// divided that same 56 by a ~845-unit *logical* window dimension, self.render.resize((width as f32, height as f32));
// and the row rendered far too short rather than too tall or
// right, because a fixed-size item's absolute unit value never // **Reuse the existing renderer (device, atlas, buffers, bind
// adapts to the mismatch the way a `rest(n)`-proportional one // groups) when one is already live -- only reconfigure the
// does. Found by measuring a fresh install's top button row at // surface.** `surfaceChanged` fires on *every* size or format
// ~40 physical px instead of the ~147px `56 * content_scale` // change, not only on a genuinely new `Surface`/window: showing
// predicts, immediately after the density fix below was added. // the IME under `adjustResize` resizes the same `SurfaceView` and
let content_scale = self.state.android_state().content_scale; // is reported through this exact callback. Rebuilding the whole
self.render // `AndroidRenderer` here used to mean a fresh `UiRenderNode::new`
.resize((width as f32 / content_scale, height as f32 / content_scale)); // -- a brand-new, empty glyph atlas and fresh GPU buffers -- while
// Drop the old renderer (and the surface it owns) before building // `iris_core`'s CPU-side glyph cache (`primitive/text.rs`) kept the
// one from the new window -- see `AndroidRenderer`'s doc comment. // 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(); let ui_state = self.state.android_state_mut();
ui_state.renderer = None; 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 // `AndroidRenderer::new` used to panic here through wgpu's own
// default uncaptured-error handler on a bind-group-layout // default uncaptured-error handler on a bind-group-layout
// validation failure -- exactly what aborted the P0 bench APK on // 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 // 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 // person can read, since `ctx.view`/`ctx.env` (needed to reach the
// Java side) are only in scope inside a `ViewPeer` callback. // 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; let content_scale = self.state.android_state().content_scale;
match AndroidRenderer::new(window, width as u32, height as u32, content_scale) { match AndroidRenderer::new(window, width as u32, height as u32, content_scale) {
Ok(renderer) => { Ok(renderer) => {
@@ -771,15 +811,22 @@ pub fn new_peer<'local, State: AndroidAppState>(
state: Default::default(), state: Default::default(),
_state: PhantomData, _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 shared = Rc::new(RefCell::new(Shared::default()));
let ui_state = AndroidUiState::new(shared.clone(), content_scale); let ui_state = AndroidUiState::new(shared.clone(), content_scale);
let mut state = State::new(ui_state, &mut rsc); let mut state = State::new(ui_state, &mut rsc);
let platform_vm = env.get_java_vm().unwrap(); let platform_vm = env.get_java_vm().unwrap();
let platform_view = env.new_global_ref(&view.0).unwrap(); let platform_view = env.new_global_ref(&view.0).unwrap();
state.platform_ready(&mut rsc, platform_vm, platform_view); 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 { let peer = IrisViewPeer {
rsc, rsc,
render: UiRenderState::new(), render,
state, state,
task_recv, task_recv,
}; };
+2 -1
View File
@@ -752,9 +752,10 @@ impl List {
let axis = self.axis; let axis = self.axis;
let output_len = painter.output_size().axis(axis); let output_len = painter.output_size().axis(axis);
let container_len = painter.region().axis(axis).len(); let container_len = painter.region().axis(axis).len();
let density = painter.density();
let resolve = move |used: Size| -> f32 { let resolve = move |used: Size| -> f32 {
used.axis(axis) used.axis(axis)
.apply_rest() .apply_rest(density)
.within_len(container_len) .within_len(container_len)
.to_abs(output_len) .to_abs(output_len)
}; };
+4 -3
View File
@@ -17,14 +17,15 @@ impl Widget for Aligned {
// already-resolved region double-applies that composition and is // already-resolved region double-applies that composition and is
// wrong for any widget nested below the root. // wrong for any widget nested below the root.
let used = painter.widget(&self.inner); let used = painter.widget(&self.inner);
let density = painter.density();
let region = match self.align.tuple() { let region = match self.align.tuple() {
(Some(x), Some(y)) => used.to_uivec2().align(RegionAlign { x, y }), (Some(x), Some(y)) => used.to_uivec2(density).align(RegionAlign { x, y }),
(Some(x), None) => { (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) UiRegion::new(x, UiSpan::FULL)
} }
(None, Some(y)) => { (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) UiRegion::new(UiSpan::FULL, y)
} }
(None, None) => UiRegion::FULL, (None, None) => UiRegion::FULL,
+10 -9
View File
@@ -9,12 +9,12 @@ pub struct MaxSize {
impl MaxSize { impl MaxSize {
/// Caps a reported length at `max`, comparing in pixels since `Len`'s /// Caps a reported length at `max`, comparing in pixels since `Len`'s
/// rel/abs/rest components are not otherwise comparable. /// 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 { let Some(max) = max else {
return len; return len;
}; };
let len_px = len.apply_rest().to_abs(output); let len_px = len.apply_rest(density).to_abs(output);
let max_px = max.apply_rest().to_abs(output); let max_px = max.apply_rest(density).to_abs(output);
if len_px > max_px { max } else { len } 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 /// start, if it does not. Needed so the child is never painted bigger
/// than the size this widget reports for it -- see the identical /// than the size this widget reports for it -- see the identical
/// requirement noted on `Sized::draw`. /// 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 { let Some(max) = max else {
return UiSpan::FULL; return UiSpan::FULL;
}; };
let max_scalar = max.apply_rest(); let max_scalar = max.apply_rest(density);
let max_px = max_scalar.to_abs(output); let max_px = max_scalar.to_abs(output);
if offered_px > max_px { if offered_px > max_px {
max_scalar.align(AxisAlign::Neg) max_scalar.align(AxisAlign::Neg)
@@ -41,15 +41,16 @@ impl MaxSize {
impl Widget for MaxSize { impl Widget for MaxSize {
fn draw(&mut self, painter: &mut Painter) -> Size { fn draw(&mut self, painter: &mut Painter) -> Size {
let output = painter.output_size(); let output = painter.output_size();
let density = painter.density();
let offered = painter.px_size(); let offered = painter.px_size();
let region = UiRegion { let region = UiRegion {
x: Self::clamp_region(offered.x, self.x, output.x), x: Self::clamp_region(offered.x, self.x, output.x, density),
y: Self::clamp_region(offered.y, self.y, output.y), y: Self::clamp_region(offered.y, self.y, output.y, density),
}; };
let used = painter.widget_within(&self.inner, region); let used = painter.widget_within(&self.inner, region);
Size { Size {
x: Self::clamp(used.x, self.x, output.x), x: Self::clamp(used.x, self.x, output.x, density),
y: Self::clamp(used.y, self.y, output.y), y: Self::clamp(used.y, self.y, output.y, density),
} }
} }
} }
+57 -44
View File
@@ -7,9 +7,12 @@ pub struct Pad {
impl Widget for Pad { impl Widget for Pad {
fn draw(&mut self, painter: &mut Painter) -> Size { fn draw(&mut self, painter: &mut Painter) -> Size {
let used = painter.widget_within(&self.inner, self.padding.region()); let density = painter.density();
let width = self.padding.left + self.padding.right; let used = painter.widget_within(&self.inner, self.padding.region(density));
let height = self.padding.top + self.padding.bottom; 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 { Size {
x: used.x + Len::abs(width), x: used.x + Len::abs(width),
y: used.y + Len::abs(height), 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 struct Padding {
pub left: f32, pub left: Len,
pub right: f32, pub right: Len,
pub top: f32, pub top: Len,
pub bottom: f32, pub bottom: Len,
} }
impl Padding { impl Padding {
pub const ZERO: Self = Self { pub const ZERO: Self = Self {
left: 0.0, left: Len::ZERO,
right: 0.0, right: Len::ZERO,
top: 0.0, top: Len::ZERO,
bottom: 0.0, bottom: Len::ZERO,
}; };
pub fn uniform(amt: impl UiNum) -> Self { pub fn uniform(amt: impl Into<Len>) -> Self {
let amt = amt.to_f32(); let amt = amt.into();
Self { Self {
left: amt, left: amt,
right: amt, right: amt,
@@ -41,80 +50,84 @@ impl Padding {
bottom: amt, bottom: amt,
} }
} }
pub fn region(&self) -> UiRegion { pub fn region(&self, density: f32) -> UiRegion {
let mut region = UiRegion::FULL; let mut region = UiRegion::FULL;
region.x.start.abs += self.left; region.x.start.abs += self.left.apply_rest(density).abs;
region.y.start.abs += self.top; region.y.start.abs += self.top.apply_rest(density).abs;
region.x.end.abs -= self.right; region.x.end.abs -= self.right.apply_rest(density).abs;
region.y.end.abs -= self.bottom; region.y.end.abs -= self.bottom.apply_rest(density).abs;
region region
} }
pub fn x(amt: impl UiNum) -> Self { pub fn x(amt: impl Into<Len>) -> Self {
let amt = amt.to_f32(); let amt = amt.into();
Self { Self {
left: amt, left: amt,
right: amt, right: amt,
top: 0.0, top: Len::ZERO,
bottom: 0.0, bottom: Len::ZERO,
} }
} }
pub fn y(amt: impl UiNum) -> Self { pub fn y(amt: impl Into<Len>) -> Self {
let amt = amt.to_f32(); let amt = amt.into();
Self { Self {
left: 0.0, left: Len::ZERO,
right: 0.0, right: Len::ZERO,
top: amt, top: amt,
bottom: amt, bottom: amt,
} }
} }
pub fn top(amt: impl UiNum) -> Self { pub fn top(amt: impl Into<Len>) -> Self {
let mut s = Self::ZERO; let mut s = Self::ZERO;
s.top = amt.to_f32(); s.top = amt.into();
s s
} }
pub fn bottom(amt: impl UiNum) -> Self { pub fn bottom(amt: impl Into<Len>) -> Self {
let mut s = Self::ZERO; let mut s = Self::ZERO;
s.bottom = amt.to_f32(); s.bottom = amt.into();
s s
} }
pub fn left(amt: impl UiNum) -> Self { pub fn left(amt: impl Into<Len>) -> Self {
let mut s = Self::ZERO; let mut s = Self::ZERO;
s.left = amt.to_f32(); s.left = amt.into();
s s
} }
pub fn right(amt: impl UiNum) -> Self { pub fn right(amt: impl Into<Len>) -> Self {
let mut s = Self::ZERO; let mut s = Self::ZERO;
s.right = amt.to_f32(); s.right = amt.into();
s s
} }
pub fn with_top(mut self, amt: impl UiNum) -> Self { pub fn with_top(mut self, amt: impl Into<Len>) -> Self {
self.top = amt.to_f32(); self.top = amt.into();
self self
} }
pub fn with_bottom(mut self, amt: impl UiNum) -> Self { pub fn with_bottom(mut self, amt: impl Into<Len>) -> Self {
self.bottom = amt.to_f32(); self.bottom = amt.into();
self self
} }
pub fn with_left(mut self, amt: impl UiNum) -> Self { pub fn with_left(mut self, amt: impl Into<Len>) -> Self {
self.left = amt.to_f32(); self.left = amt.into();
self self
} }
pub fn with_right(mut self, amt: impl UiNum) -> Self { pub fn with_right(mut self, amt: impl Into<Len>) -> Self {
self.right = amt.to_f32(); self.right = amt.into();
self self
} }
} }
impl<T: UiNum> From<T> for Padding { /// Covers both a bare number (`.pad(8)`, via `Len`'s own `From<N: UiNum>`
/// blanket -- an `abs`/physical-pixel `Len`) and a `Len` directly
/// (`.pad(dp(10))`) with the one impl, since `Len: Into<Len>` is the
/// reflexive case of the same bound.
impl<T: Into<Len>> From<T> for Padding {
fn from(amt: T) -> Self { fn from(amt: T) -> Self {
Self::uniform(amt.to_f32()) Self::uniform(amt.into())
} }
} }
+1 -1
View File
@@ -43,7 +43,7 @@ impl Widget for Scroll {
self.content_len = used self.content_len = used
.axis(axis) .axis(axis)
.apply_rest() .apply_rest(painter.density())
.within_len(container_len) .within_len(container_len)
.to_abs(output_len); .to_abs(output_len);
+3 -2
View File
@@ -17,12 +17,13 @@ impl Widget for Sized {
// learn its size, then moves it into place with a pure // learn its size, then moves it into place with a pure
// translation; that translation is only valid if what got painted // translation; that translation is only valid if what got painted
// is already the reported size, anchored the same way both times. // is already the reported size, anchored the same way both times.
let density = painter.density();
let mut region = UiRegion::FULL; let mut region = UiRegion::FULL;
if let Some(x) = self.x { 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 { 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); let used = painter.widget_within(&self.inner, region);
Size { Size {
+16 -10
View File
@@ -4,12 +4,18 @@ use std::marker::PhantomData;
pub struct Span { pub struct Span {
pub children: Vec<StrongWidget>, pub children: Vec<StrongWidget>,
pub dir: Dir, 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 { impl Widget for Span {
fn draw(&mut self, painter: &mut Painter) -> Size { fn draw(&mut self, painter: &mut Painter) -> Size {
let axis = self.dir.axis; 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) // Phase 1: draw each child once, at the ambient (unmodified, full)
// region a size-only query used to see before this migration, to // 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)) .map(|child| painter.widget(child).axis(axis))
.collect(); .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); let total = lens.iter().fold(Len::abs(gap_total), |s, &l| s + l);
// Phase 2: place each child for real, using the lengths just // Phase 2: place each child for real, using the lengths just
@@ -54,7 +60,7 @@ impl Widget for Span {
child_region.flip(axis); child_region.flip(axis);
} }
let used = painter.widget_within(child, child_region); let used = painter.widget_within(child, child_region);
start.abs += self.gap; start.abs += gap;
let ortho = used.axis(!axis); let ortho = used.axis(!axis);
if ortho.rel > 0.0 || ortho.rest > 0.0 { if ortho.rel > 0.0 || ortho.rest > 0.0 {
@@ -82,12 +88,12 @@ impl Span {
Self { Self {
children: Vec::new(), children: Vec::new(),
dir, dir,
gap: 0.0, gap: Len::ZERO,
} }
} }
pub fn gap(mut self, gap: impl UiNum) -> Self { pub fn gap(mut self, gap: impl Into<Len>) -> Self {
self.gap = gap.to_f32(); self.gap = gap.into();
self self
} }
@@ -103,7 +109,7 @@ impl Span {
pub struct SpanBuilder<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag> { pub struct SpanBuilder<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag> {
pub children: Wa, pub children: Wa,
pub dir: Dir, pub dir: Dir,
pub gap: f32, pub gap: Len,
_pd: PhantomData<(State, Tag)>, _pd: PhantomData<(State, Tag)>,
} }
@@ -129,13 +135,13 @@ impl<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag>
Self { Self {
children, children,
dir, dir,
gap: 0.0, gap: Len::ZERO,
_pd: PhantomData, _pd: PhantomData,
} }
} }
pub fn gap(mut self, gap: impl UiNum) -> Self { pub fn gap(mut self, gap: impl Into<Len>) -> Self {
self.gap = gap.to_f32(); self.gap = gap.into();
self self
} }
} }
+2 -1
View File
@@ -141,7 +141,8 @@ impl<'a> TextEditCtx<'a> {
fn layout(&mut self) -> &Layout<UiColor> { fn layout(&mut self) -> &Layout<UiColor> {
let attrs = self.text.view.attrs.clone(); let attrs = self.text.view.attrs.clone();
let width = self.text.view.wrap_width(); 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() self.text.view.buf.layout()
} }
+1 -1
View File
@@ -39,7 +39,7 @@ where
.label("Message") .label("Message")
.add(rsc); .add(rsc);
let bar: WeakWidget = (field.pad(12).width(rest(1)),) let bar: WeakWidget = (field.pad(dp(12)).width(rest(1)),)
.span(Dir::RIGHT) .span(Dir::RIGHT)
.background(rect(UiColor::new(40, 40, 46, 255))) .background(rect(UiColor::new(40, 40, 46, 255)))
.add(rsc); .add(rsc);
+2 -2
View File
@@ -174,8 +174,8 @@ where
(header, field.width(rest(1))) (header, field.width(rest(1)))
.span(Dir::DOWN) .span(Dir::DOWN)
.gap(4) .gap(dp(4))
.pad(10) .pad(dp(10))
.add_strong(rsc) .add_strong(rsc)
.any() .any()
} }