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:
commit
4afc453faa
25 files changed
+838
-205
No files matched your search
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -114,10 +114,17 @@ pub struct BenchClient {
|
||||
running: bool,
|
||||
/// The keyboard phase's own confirmation channel -- updated from
|
||||
/// `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
|
||||
/// `ctx.update`, since neither side needs the widget tree for this.
|
||||
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`
|
||||
@@ -241,7 +248,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)
|
||||
@@ -277,6 +284,7 @@ impl AndroidAppState for BenchClient {
|
||||
last_report: None,
|
||||
running: false,
|
||||
ime_state: Arc::new(Mutex::new(ImeState::default())),
|
||||
keyboard_was_visible: false,
|
||||
};
|
||||
|
||||
let (backlog, stream_tail) = parse_fixture();
|
||||
@@ -305,38 +313,93 @@ impl AndroidAppState for BenchClient {
|
||||
/// field comment. Rebuilds the row rather than mutating a stored
|
||||
/// `Padding` in place, since nothing here holds a handle to one.
|
||||
///
|
||||
/// Also the keyboard phase's own confirmation signal: `ime_bottom`
|
||||
/// crossing 0<->positive is the platform's own answer for whether the
|
||||
/// IME actually opened or closed, fired from the real insets
|
||||
/// callback rather than assumed from having called `show_ime`/
|
||||
/// `hide_ime` -- see `ime_state`'s doc.
|
||||
/// Also two things downstream of the same `ime_bottom` transition:
|
||||
/// **the keyboard phase's own confirmation signal** (`ime_state`'s
|
||||
/// doc -- the platform's own answer for whether the IME actually
|
||||
/// opened or closed, rather than assumed from having called
|
||||
/// `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(
|
||||
&mut self,
|
||||
rsc: &mut AndroidRsc<Self>,
|
||||
insets: iris::android::LogicalInsets,
|
||||
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;
|
||||
|
||||
let mut ime = self.ime_state.lock().unwrap();
|
||||
let now_visible = insets.ime_bottom > 0.0;
|
||||
if now_visible && !ime.visible {
|
||||
if ime_visible && !ime.visible {
|
||||
ime.shown_events += 1;
|
||||
}
|
||||
if !now_visible && ime.visible {
|
||||
if !ime_visible && ime.visible {
|
||||
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>;
|
||||
|
||||
/// `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(
|
||||
@@ -351,7 +414,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))
|
||||
@@ -367,7 +430,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))
|
||||
@@ -383,12 +446,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()
|
||||
@@ -428,6 +493,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");
|
||||
|
||||
@@ -222,4 +222,31 @@ impl PlatformHandle {
|
||||
.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(())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -752,9 +752,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)
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user