iris is the framework alone; the app is one crate in app-rust/

Iris: "the organization of the rust rewrite is a mess right now... there
shouldn't be anything related to the app inside of iris. Iris is supposed
to be the UI framework alone." And, on the crate count: "I'm confused why
the app only code needs more than one crate though."

Nine cargo workspaces become three, and the port's project code -- which
sat in five places, four of them inside the framework -- becomes one crate,
`ai-app`, in `app-rust/`:

  client-core                -> app-rust/src/client
  iris/transcript-ui         -> app-rust/src/ui
  iris/transcript-fixture    -> app-rust/src/ui/fixture.rs + tests/ + touch/
  iris/desktop-app           -> app-rust/src/desktop + src/bin_desktop.rs
  iris/android-app           -> app-rust/src/android + android-project/
  android-shell              -> app-rust/src/shell

iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now
mentions no session, transcript, setup or server anywhere.

Only two of the old splits had a reason that survived reading. event-model
stays a crate at the repo root because server/ depends on it too, so a
crate is what makes the backend and the app agree by construction. The two
Android .so names looked like a hard constraint -- a package produces one
library artifact -- until P2 turned out to already plan merging those two
Android apps into one; both faces now come out of libai_app.so, picked
apart by features so `--no-default-features --features shell` keeps wgpu,
parley and iris out of the Compose app's APK. docs/RUST.md's "One app
crate" has the rest, including what each remaining feature is for.

DECISIONS.md and SUBAGENTS.md move into docs/ with everything else.

Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so,
build-apk.sh produces an APK that installs and launches on this checkout's
emulator (Gl ... virgl, as expected), and the phone-sized headless
screenshot renders the transcript unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 23:36:38 -04:00
1 parent 7b54aaf3c4
commit a9312e9431
113 files changed
+23221 -2992

No files matched your search

+526
View File
@@ -0,0 +1,526 @@
use crate::task::RequestRedraw;
use android_view::{
View,
jni::{JavaVM, objects::GlobalRef},
ndk::native_window::NativeWindow,
};
use iris_core::{UiData, UiRenderNode, UiRenderState};
use pollster::FutureExt;
use std::time::{Duration, Instant};
use wgpu::{
rwh::{DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WindowHandle},
*,
};
pub const CLEAR_COLOR: Color = Color::BLACK;
/// `NativeWindow` (from the surface android-view hands over in
/// `surfaceChanged`) has a window handle but not a display one -- there is
/// exactly one display on Android and `rwh` has a unit variant for it.
/// Mirrors android-view's own demo (`demo/src/lib.rs`'s
/// `AndroidWindowHandle`).
struct AndroidWindowHandle {
window: NativeWindow,
}
impl HasDisplayHandle for AndroidWindowHandle {
fn display_handle(&self) -> Result<DisplayHandle<'_>, HandleError> {
Ok(DisplayHandle::android())
}
}
impl HasWindowHandle for AndroidWindowHandle {
fn window_handle(&self) -> Result<WindowHandle<'_>, HandleError> {
self.window.window_handle()
}
}
/// The android-view surface, unlike winit's window, does not outlive a
/// backgrounding of the activity: `surfaceDestroyed`/`surfaceCreated` (via
/// `SurfaceHolder.Callback`) recreate it, so this holds everything that
/// depends on that surface rather than being built once at startup --
/// `AndroidUiState` holds it as `Option<AndroidRenderer>`, `None` exactly
/// when there is no surface to draw into.
pub struct AndroidRenderer {
surface: Surface<'static>,
device: Device,
queue: Queue,
config: SurfaceConfiguration,
encoder: CommandEncoder,
pub ui: UiRenderNode,
/// The adapter identity, kept past `new()` for the Diagnostics page --
/// `Adapter` itself is not `Clone`, so the three fields the page shows
/// are copied out once here rather than holding the adapter.
pub adapter_name: String,
pub adapter_backend: Backend,
pub adapter_driver: String,
/// Every uncaptured wgpu error since this renderer was created -- see
/// `iris_core::WgpuErrorLog`'s doc comment. Installed on `device` in
/// `new()`, kept here so the Diagnostics page and the per-frame log in
/// `update()` can both read it without a global.
pub wgpu_errors: iris_core::WgpuErrorLog,
/// Frames drawn on this surface -- what gates the first-10-frames log
/// `update()` writes (RUST.md's P0 box, "the first input frame"
/// investigation): a fresh surface is exactly what Iris's own report
/// says renders correctly at first, so the frames that matter are the
/// first several after each `surface_changed`, not an arbitrary window
/// during a long-running session.
frame_count: u64,
/// Physical pixels per dp -- see `android::view::AndroidUiState::
/// content_scale`'s field comment for what this feeds.
content_scale: f32,
}
/// One frame's worth of the counters `render/mod.rs`'s doc comments on
/// `FrameUpdateStats`/`take_image_bind_group_creates`/
/// `take_atlas_pages_grown` describe -- assembled here because the three
/// live on two different calling conventions (`FrameUpdateStats` from this
/// exact `update()` call; the other two describe the *previous* frame,
/// same as `bench_images`' existing use of them) and a diagnostic reader
/// should not have to know that split.
#[derive(Clone, Copy, Debug, Default)]
pub struct FrameDiagnostics {
pub masks_resized: bool,
pub moves_resized: bool,
/// From the previous frame's `update()` -- see the struct doc.
pub atlas_pages_grown_prev: u64,
pub image_bind_group_creates_prev: u64,
}
impl AndroidRenderer {
/// `Err` holds a full, human-readable report for **every** way this
/// can fail -- no surface, no adapter, no device, or wgpu's own error
/// text (`UiRenderNode::new`'s doc comment) plus the adapter identity
/// and the limits/downlevel flags bind-group-layout validation checks
/// against -- rather than the panic wgpu's default error handler would
/// otherwise raise with no caller able to see it. This is what aborted
/// the P0 bench APK on Iris's phone with only "wgpu error: Validation
/// Error" surviving into the crash report (RUST.md's P0 box, "iris
/// bench crash on the phone, 2026-09-06"): `create_bind_group_layout`
/// validates against *this* adapter's downlevel capabilities and
/// limits, which a desktop GPU and the emulator's software renderers
/// never exercised. The caller (`android::view::IrisViewPeer::
/// surface_changed`) logs this one-line-flattened and shows it on
/// screen instead of aborting the process.
pub fn new(
window: NativeWindow,
width: u32,
height: u32,
content_scale: f32,
) -> Result<Self, String> {
// `force-gles` (RUST.md's I5 "Where iris's frame time goes") pins
// the build to GLES, to isolate whether the backend itself explains
// the frame time gap against Compose. `cfg!` rather than a runtime
// switch: there is no way to hand an env var to an already-launched
// Android process on this machine (see the feature's doc in
// Cargo.toml).
//
// Otherwise: **Vulkan where it has an adapter at all, GLES where it
// has none.** `Backends::PRIMARY` leaves `GL` out, so a device
// offering only a GLES adapter had no adapter at all and this
// function aborted the process -- this checkout's emulator, whose
// Vulkan ICD carries no adapter behind it (`NotFound {
// active_backends: VULKAN, no_adapter_backends: VULKAN,
// supported_backends: VULKAN | GL }`), and the crash loop in
// RUST.md's queue.
//
// The choice is made *before any surface exists*, with an instance
// that never touches the window, because **an Android window can be
// connected to one graphics API only**. One instance carrying both
// backends does not work: `create_surface` builds a raw surface per
// backend, Vulkan's `vkCreateAndroidSurfaceKHR` claims the window
// first, and the GLES surface made from the same window then fails
// `configure` as lost -- measured here as "In Surface::configure /
// Invalid surface" followed by an abort in
// `Surface::get_current_texture_view`, "Surface is not configured
// for presentation".
let mut backends = if cfg!(feature = "force-gles") {
Backends::GL
} else {
Backends::PRIMARY
};
// No display handle: an Android surface is built from the
// `NativeWindow` below, and there is no platform connection to hand
// wgpu here the way there is on Wayland.
let mut instance = Instance::new(InstanceDescriptor {
backends,
..InstanceDescriptor::new_without_display_handle()
});
// A build already pinned to GLES has nowhere to fall back to.
if backends != Backends::GL && instance.enumerate_adapters(backends).block_on().is_empty() {
log::warn!(
"iris renderer: no {backends:?} adapter on this device, falling back to GLES"
);
backends = Backends::GL;
instance = Instance::new(InstanceDescriptor {
backends,
..InstanceDescriptor::new_without_display_handle()
});
}
// SAFETY: the `NativeWindow` outlives the surface built from it --
// android-view drops the old renderer (and this surface with it)
// before handing over a new window, in `surface_changed` below.
let surface = instance
.create_surface(SurfaceTarget::from(AndroidWindowHandle { window }))
.map_err(|error| format!("Could not create the android surface: {error}"))?;
// Every step from here to a live device reports rather than
// panics, for the one reason: on the phone these builds run on
// there is no `adb`, so an abort's message reaches a tombstone
// nobody can read and the launcher simply restarts the app --
// which is what a crash loop with no explanation is. The caller
// (`android::view::IrisViewPeer::surface_changed`) puts this
// string on screen and in the app's own log ring instead.
let adapter = instance
.request_adapter(&RequestAdapterOptions {
power_preference: PowerPreference::default(),
compatible_surface: Some(&surface),
force_fallback_adapter: false,
..Default::default()
})
.block_on()
.map_err(|error| format!("No usable GPU adapter for backends {backends:?}: {error}"))?;
// Same request as the winit backend's `UiRenderer::new` -- no
// binding-array features, see TEXTURES.md's "Recommended shape".
// `iris_core::device_limits()` is shared between the two backends;
// see its own doc for why it is not simply `Limits::default()`.
let (device, queue) = adapter
.request_device(&DeviceDescriptor {
required_limits: iris_core::device_limits(),
..Default::default()
})
.block_on()
.map_err(|error| {
format!(
"The adapter {} ({:?}) refused a device: {error}",
adapter.get_info().name,
adapter.get_info().backend,
)
})?;
// wgpu's default handler for an error raised outside `UiRenderNode::
// new`'s own error scopes (i.e. everything past device creation --
// an ordinary frame's `update`/`draw`) is `panic!`, unconditionally,
// with no caller able to intervene: the same mechanism that aborted
// the P0 bench APK once already, just at a different call site. Log
// and record instead of letting that default stand -- RUST.md's P0
// box, "every wgpu uncaptured error ... it must never panic in
// release".
let wgpu_errors = iris_core::WgpuErrorLog::default();
let wgpu_errors_for_handler = wgpu_errors.clone();
device.on_uncaptured_error(std::sync::Arc::new(move |error| {
log::error!("iris wgpu uncaptured error: {error}");
wgpu_errors_for_handler.record(error);
}));
let info = adapter.get_info();
let adapter_name = info.name.clone();
let adapter_backend = info.backend;
// Either half can be empty -- the emulator's GLES adapter reports
// no `driver` and a long `driver_info`, so joining unconditionally
// left a leading space in every log line it appears in.
let adapter_driver = [info.driver.as_str(), info.driver_info.as_str()]
.into_iter()
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" ");
// Say which adapter won, in the same words `default::render` uses,
// and at startup rather than only on the Diagnostics page: the
// backend alone (logged by `view.rs` when a renderer is built) does
// not separate the cases that matter. In this checkout's emulator
// `Gl` is the host's real GPU through virgl, and `Gl` under
// `EMU_GPU=software` is SwiftShader on the CPU; on a phone `Vulkan`
// is the device's own driver. A frame time or a screenshot with no
// record of which of those produced it cannot be read, and the
// fallback above is silent by design.
log::info!(
"iris renderer: {adapter_name} ({adapter_backend:?}, {adapter_driver}) on \
{backends:?}"
);
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
.iter()
.copied()
.find(|f| f.is_srgb())
.unwrap_or(surface_caps.formats[0]);
let config = SurfaceConfiguration {
usage: TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
// wgpu 30's new field; `Auto` is what every earlier version did.
color_space: SurfaceColorSpace::Auto,
width,
height,
present_mode: PresentMode::AutoVsync,
alpha_mode: surface_caps.alpha_modes[0],
desired_maximum_frame_latency: 2,
view_formats: vec![],
};
surface.configure(&device, &config);
let encoder = Self::create_encoder(&device);
// Physical pixels, matching the swapchain's own `width`/`height`
// exactly -- see `android::view::AndroidUiState::content_scale`'s
// field comment for why this is no longer divided into a separate
// logical space (that stopgap is what made text blurry, RUST.md's
// P0 box). `Len::dp` folds the density in at layout time instead,
// so nothing here needs to know it at all.
let window_size = iris_core::util::Vec2::new(width as f32, height as f32);
let ui = match UiRenderNode::new(&device, &queue, &config, window_size) {
Ok(ui) => ui,
Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)),
};
Ok(Self {
surface,
device,
queue,
config,
encoder,
ui,
adapter_name,
adapter_backend,
adapter_driver,
wgpu_errors,
frame_count: 0,
content_scale,
})
}
/// The adapter identity plus every limit and downlevel flag
/// `create_bind_group_layout` validates a storage buffer or texture
/// binding against, followed by wgpu's own error text -- everything a
/// person reading this off a screenshot needs to tell "this adapter
/// lacks X" from "this is a bug in the layout." Named explicitly rather
/// than `{limits:?}`/`{flags:?}` wholesale, because `Limits` alone is
/// dozens of fields nobody asked for -- these are exactly the ones
/// `UiRenderNode::new`'s layouts (`rsc_layout`, `masks_layout`,
/// `primitive_layout`) can fail against, per `CreateBindGroupLayoutError`
/// (`wgpu-core::binding_model`) and its downlevel-flag checks
/// (`wgpu-core::device::resource`, `VERTEX_STORAGE` in particular --
/// the one storage buffer here, `move_offsets`, that is visible to the
/// vertex stage).
fn diagnostic(adapter: &Adapter, wgpu_error: &str) -> String {
let info = adapter.get_info();
let limits = adapter.limits();
let downlevel = adapter.get_downlevel_capabilities();
format!(
"iris could not start rendering. Copy this text and send it to Iris.\n\n\
adapter: {name} ({backend:?}), driver: {driver} {driver_info}\n\
limits: max_storage_buffers_per_shader_stage={max_storage_buffers} \
max_sampled_textures_per_shader_stage={max_sampled_textures} \
max_bind_groups={max_bind_groups} \
max_bindings_per_bind_group={max_bindings} \
max_storage_buffer_binding_size={max_storage_binding} \
min_storage_buffer_offset_alignment={min_storage_align}\n\
downlevel flags: {flags:?}\n\n\
{wgpu_error}",
name = info.name,
backend = info.backend,
driver = info.driver,
driver_info = info.driver_info,
max_storage_buffers = limits.max_storage_buffers_per_shader_stage,
max_sampled_textures = limits.max_sampled_textures_per_shader_stage,
max_bind_groups = limits.max_bind_groups,
max_bindings = limits.max_bindings_per_bind_group,
max_storage_binding = limits.max_storage_buffer_binding_size,
min_storage_align = limits.min_storage_buffer_offset_alignment,
flags = downlevel.flags,
)
}
/// The Diagnostics page's whole report: adapter identity, font
/// resolution, the atlas's own view count, every uncaptured wgpu error
/// so far, and the frame report -- RUST.md's P0 box, "a named
/// `Diagnostics` control ... adapter info, limits, fonts found, atlas
/// format/pages, wgpu errors so far, frame report". One string rather
/// than a struct the caller formats, since the only consumer is a
/// plain `TextView` with a "copy this and send it to Iris" affordance,
/// the same shape `surface_changed`'s crash report already uses
/// (UI_RULES.md: a failure -- or here, a state worth reporting --
/// carries enough to act on where it's shown).
pub fn diagnostics_report(
&self,
font: &iris_core::FontDiagnostics,
frame_report: &str,
) -> String {
let errors = self.wgpu_errors.snapshot();
let errors_text = if errors.is_empty() {
"none".to_string()
} else {
errors.join("\n ")
};
format!(
"iris diagnostics. Copy this text and send it to Iris.\n\n\
adapter: {name} ({backend:?}), driver: {driver}\n\
content_scale: {content_scale}\n\
atlas format: Rgba8Unorm, views live: {views}\n\
fonts: {families_found} families found, default={default_family:?} \
mono={default_mono_family:?}\n\
fonts resolved: regular={regular:?} bold={bold:?} italic={italic:?} \
mono={mono:?}\n\
icon font: {icons:?}\n\
wgpu errors since surface creation:\n {errors_text}\n\n\
{frame_report}",
name = self.adapter_name,
backend = self.adapter_backend,
driver = self.adapter_driver,
content_scale = self.content_scale,
views = self.ui.view_count(),
families_found = font.families_found,
default_family = font.default_family,
default_mono_family = font.default_mono_family,
regular = font.regular_resolved,
bold = font.bold_resolved,
italic = font.italic_resolved,
mono = font.mono_resolved,
icons = font.icon_family,
)
}
fn create_encoder(device: &Device) -> CommandEncoder {
device.create_command_encoder(&CommandEncoderDescriptor {
label: Some("Render Encoder"),
})
}
/// Returns what changed this frame -- see `FrameDiagnostics`'s doc
/// comment for why two of its four fields describe the *previous*
/// frame rather than this one. `IrisViewPeer::render` logs this for
/// the first `DIAGNOSTIC_FRAMES` frames after each `surface_changed`,
/// per RUST.md's P0 box ("the first input frame" investigation): the
/// glyph-wipe Iris reported happens on the first tap or scroll after a
/// fresh surface, so that is exactly the window a report needs to
/// cover, not an arbitrary slice of a long session.
pub fn update(&mut self, ui: &mut UiData, render: &mut UiRenderState) -> FrameDiagnostics {
let atlas_pages_grown_prev = self.ui.take_atlas_pages_grown();
let image_bind_group_creates_prev = self.ui.take_image_bind_group_creates();
let stats = self.ui.update(&self.device, &self.queue, ui, render);
self.frame_count += 1;
FrameDiagnostics {
masks_resized: stats.masks_resized,
moves_resized: stats.moves_resized,
atlas_pages_grown_prev,
image_bind_group_creates_prev,
}
}
/// Frames drawn on this surface so far -- see `frame_count`'s field
/// comment.
pub fn frame_count(&self) -> u64 {
self.frame_count
}
/// Draws and presents one frame, returning the time spent in
/// `queue.submit` plus `present()` -- wherever a driver/GPU/compositor
/// wait would actually show up. The caller (`android::view::render`)
/// already times the whole frame from its own `redraw_to_submit` start;
/// subtracting this from that total is `redraw_to_submit` itself
/// (layout, text, primitive building, and this method's own render-pass
/// recording). RUST.md's I5 "Where iris's frame time goes" diagnosis,
/// added 2026-09-05 -- see `iris_core::FrameReport::record_split`'s own
/// doc for the caveat this shares: `present()` is not fenced against
/// the GPU actually finishing, so this is "how long the CPU was blocked
/// handing the frame off", not confirmed GPU time.
pub fn draw(&mut self) -> Duration {
let output = match self.surface.get_current_texture() {
CurrentSurfaceTexture::Success(texture)
| CurrentSurfaceTexture::Suboptimal(texture) => texture,
// wgpu 30 turned this Result into an enum; every arm here was an
// `Err` the previous `.unwrap()` panicked on, except `Occluded`,
// which is new.
other => panic!("no surface texture to draw into: {other:?}"),
};
let view = output
.texture
.create_view(&TextureViewDescriptor::default());
let mut encoder = std::mem::replace(&mut self.encoder, Self::create_encoder(&self.device));
{
let render_pass = &mut encoder.begin_render_pass(&RenderPassDescriptor {
color_attachments: &[Some(RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: Operations {
load: LoadOp::Clear(CLEAR_COLOR),
store: StoreOp::Store,
},
depth_slice: None,
})],
..Default::default()
});
self.ui.draw(render_pass);
}
let submit_start = Instant::now();
self.queue.submit(std::iter::once(encoder.finish()));
self.queue.present(output);
submit_start.elapsed()
}
/// Physical pixels -- the unit layout and hit-testing use, matching
/// the window uniform's own units. See
/// `android::view::AndroidUiState::content_scale`'s field comment.
pub fn size(&self) -> iris_core::util::Vec2 {
iris_core::util::Vec2::new(self.config.width as f32, self.config.height as f32)
}
/// Reconfigures the surface and rewrites the window uniform for a new
/// physical size -- deliberately the *only* two things this does.
/// `device`, `ui`'s atlas, buffers and bind groups are untouched, so a
/// call here (as opposed to a fresh `AndroidRenderer::new`) never
/// invalidates a glyph the CPU-side cache already placed in the atlas.
/// See `android::view::IrisViewPeer::surface_changed`'s doc comment for
/// why that distinction matters -- it is what keeps text on screen
/// across an IME resize.
pub fn resize(&mut self, width: u32, height: u32) {
self.config.width = width;
self.config.height = height;
self.surface.configure(&self.device, &self.config);
let size = iris_core::util::Vec2::new(width as f32, height as f32);
self.ui.resize(size, &self.queue);
}
}
/// `Tasks`' redraw handle on Android: a background task finishes on the
/// tokio thread `Tasks::init` spawned, which is not attached to the JVM, so
/// asking for a frame means attaching first. The global ref is what
/// survives past the JNI call that handed the `View` to us.
///
/// **Goes through `View::post_delayed`, not `post_frame_callback`
/// directly** -- found the hard way (RUST.md's I5 Android integration):
/// `post_frame_callback`'s Java side calls `Choreographer.getInstance()`,
/// which throws `IllegalStateException` unless the *calling* thread already
/// has a `Looper` (`Choreographer.getInstance()`'s own contract). A tokio
/// worker thread, even freshly attached to the JVM, has none -- the crash
/// was a `JavaException` inside `View::post_frame_callback`'s `.unwrap()`,
/// aborting the process on the second `redraw.request_redraw()` any
/// android transcript-screen fetch made. `View.postDelayed(Runnable, 0)`
/// is the ordinary Android answer to "queue work onto a View's own UI
/// thread from any thread" and needs no Looper of its own; `delayed_callback`
/// below is what that Runnable resolves to on the UI thread, where a real
/// `post_frame_callback` is safe again.
pub struct AndroidRedrawHandle {
vm: JavaVM,
view: GlobalRef,
}
impl AndroidRedrawHandle {
pub fn new(vm: JavaVM, view: GlobalRef) -> Self {
Self { vm, view }
}
}
impl RequestRedraw for AndroidRedrawHandle {
fn request_redraw(&self) {
let Ok(mut env) = self.vm.attach_current_thread() else {
return;
};
let local = env.new_local_ref(&self.view).unwrap();
View(local).post_delayed(&mut env, 0);
}
}