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

+565
View File
@@ -0,0 +1,565 @@
//! `ScrollArea`: a fixed child, slid about by a [`ScrollController`].
//!
//! **`docs/SCROLL.md` is the overview** -- the one sign convention, what
//! `amt` means, and how this differs from a `LazySpan`, which scrolls
//! itself. Read it first; this file is the detail.
use crate::prelude::*;
use std::time::Instant;
/// A scrolling view over a child that is a fixed lump: it is measured
/// whole and then moved, which is what makes a scroll tick an O(1) move of
/// one subtree rather than a redraw.
///
/// **"Area" because it only scrolls a predefined one** (Iris, 2026-09-08):
/// a child that lays out lazily cannot be measured whole or moved as a
/// lump, and virtualising it inside one of these would never update which
/// rows it shows, since a scroll tick offers a same-size moved region and
/// `draw_inner` never re-enters the child. That case is `LazySpan`, which
/// owns a controller of its own instead of being wrapped in one of these.
pub struct ScrollArea {
inner: StrongWidget,
/// The position, the gesture, the fling and the pin -- everything
/// about scrolling that is not this widget's own layout, shared with
/// `LazySpan` rather than reimplemented beside it.
ctl: ScrollController,
container_len: f32,
/// How long the content is along the axis, as of the last draw --
/// `None` until this widget has drawn once.
///
/// An `Option` rather than a `0.0` that stands in for both, because
/// the two answers led somewhere different and the code could not tell
/// them apart: on the first frame the clamp computed a scroll range of
/// zero, concluded from `amt == range` that the area was sitting at
/// its end, and pinned it -- so the next frame, now knowing the real
/// length, jumped to it. A code fence therefore opened at the end of
/// its longest line, mid-word (`iris/run-headless.sh phone`,
/// 2026-09-08).
content_len: Option<f32>,
}
impl Scrollable for ScrollArea {
fn controller(&self) -> &ScrollController {
&self.ctl
}
fn controller_mut(&mut self) -> &mut ScrollController {
&mut self.ctl
}
}
impl Widget for ScrollArea {
/// A scroll area animates exactly one thing, its fling. The
/// registration that makes this run is `UiData::animate`, which
/// `WidgetLike::scrollable`'s own drag handler calls the frame a
/// release starts one.
fn tick(&mut self, now: Instant) -> bool {
self.tick_fling(now)
}
/// Measure, then place -- the same idiom `LazySpan` uses, for the same
/// reason: nothing drawn may depend on a length measured last frame.
///
/// **The child is drawn twice, and only the second decides anything.**
/// The first is handed last frame's length as a *hint*, and it exists
/// only so that the usual case, where the content's length did not
/// change, offers the same region twice: `draw_inner` then makes the
/// first call an O(1) `mov` and returns at the first line of the
/// second. A frame on which the content did grow or shrink pays one
/// real extra draw, and that is a frame on which the content was being
/// redrawn anyway.
///
/// The alternative -- place against the hint and let the next frame
/// fix it -- is what Iris found on her phone (2026-09-08): every
/// newline typed into the composer drew the field in a box one line
/// short of its text, and since that text is centred in its box it
/// hung half a line past each end. There was no next frame: nothing
/// dirtied that subtree again, so the stale placement was the last one
/// drawn, until the keyboard closed and its inset rewrite forced a
/// redraw ("it fixes itself"). **Layout is a pure function of the
/// state, not of how many frames have been drawn** (Iris, 2026-09-08)
/// -- a correction that needs a second frame is a frame drawn wrong.
fn draw(&mut self, painter: &mut Painter) -> Size {
// Every length here is resolved against the box this widget was
// **offered** (`px_size`), never `output_size`: a scroll area is
// routinely smaller than the window -- the composer's field is
// capped at six lines by a `MaxSize` around it -- and measuring
// the window instead would make the pan range, and so where the
// content sits, a function of the screen rather than of the box.
let axis = self.ctl.axis();
let container_len = painter.px_size().axis(axis);
self.container_len = container_len;
// Learned from the frame rather than passed in: a fling's
// deceleration is a physical quantity and needs the real display
// density, and `draw` is where this widget meets the only thing
// that knows it.
self.ctl.set_density(painter.density());
// Where the delta asked for since the last frame puts the content.
// Already inside the range the previous frame published, so it is
// the position to *measure* against; the clamp below is what the
// length just measured has to say about it.
let delta = self.ctl.take_delta();
let travelled = self.ctl.amt() - delta;
self.ctl.set_amt(travelled);
// The container's own length stands in as the hint until anything
// has been measured: a zero-length region on the first frame would
// place the child's primitives against a box of no size.
let hint = self.content_len.unwrap_or(container_len);
let used = painter.widget_within(&self.inner, self.child_region(hint));
// A child reporting `rel` means "this fraction of what I was
// offered", and what it was offered is this scroll area -- so the
// container, again, is what that resolves against.
let measured = used
.axis(axis)
.apply_rest(painter.density())
.to_abs(container_len);
self.content_len = Some(measured);
let range = (measured - container_len).max(0.0);
// The end-pin, and then the clamp, against the length just
// measured. Deliberately not also run before the measuring draw
// above -- clamping against the hint would let a stale length
// reduce `amt` in a way this pass cannot undo, and then where the
// content sits would depend on the previous frame after all.
//
// Only a frame with no delta of its own re-pins: the pin means
// "stay flush with the end as the content grows", and a reader who
// just scrolled away from that end has said otherwise. (A delta
// cannot be moving *toward* the end here -- the travel published
// below is zero that way while pinned, so `take_delta` has already
// clipped it.)
let amt = if self.ctl.pinned_to_end() && delta == 0.0 {
range
} else {
travelled.clamp(0.0, range)
};
self.ctl.set_amt(amt);
self.ctl.set_pinned_to_end(amt >= range);
self.ctl.set_travel(Travel {
back: amt,
fwd: range - amt,
});
// The **content's** size, not the container's. A parent that can
// grow (the composer's bar) should hug the text until its own cap
// stops it, and reporting the container instead would make this
// widget's answer a function of the answer -- the bar is sized
// from what is reported here, so it collapses to nothing and never
// recovers. What keeps the content inside the offered box is the
// mask a caller puts around it (`.scrollable(..).masked()`), not
// this number.
painter.widget_within(&self.inner, self.child_region(measured))
}
}
impl ScrollArea {
/// `pin` says which end this area opens at and clings to -- see
/// [`Pin`], and `WidgetLike::scrollable`, which is how one of these is
/// normally built.
pub fn new(inner: StrongWidget, axis: Axis, pin: Pin) -> Self {
Self {
inner,
// A fixed child is laid out from the box's negative edge
// onward, always, so the end of its content is the positive
// one -- which is what makes `Pin::End` and `Pin::Pos` the
// same pin here and different ones in a reversed `LazySpan`.
ctl: ScrollController::new(Dir::new(axis, Sign::Pos), pin),
container_len: 0.0,
content_len: None,
}
}
/// Where the child sits for a given content length: a box that long
/// along the scroll axis, pulled back by `amt`. The length is taken as
/// a parameter rather than read from `content_len`, because `draw`
/// places twice -- once against last frame's length and once against
/// the one it has just measured -- and the two must be the same
/// arithmetic.
fn child_region(&self, content_len: f32) -> UiRegion {
let axis = self.ctl.axis();
let mut region = UiRegion::FULL;
region.axis_mut(axis).end = region.axis(axis).start.offset(content_len);
region.offset(Vec2::from_axis(axis, -self.ctl.amt(), 0.0))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout_tests::TestRsc;
use crate::sense::{CursorButton, DRAG_SLOP, PointerRequests};
use iris_core::UiData;
use std::time::Duration;
/// A scroll area with 1000px of content in a 100px box, drawn once and
/// settled somewhere in the middle so a drag has room in both
/// directions.
///
/// Built and rendered for real rather than assembled field by field,
/// because a delta is spent in `draw` now (the controller banks it, and
/// only a layout knows where the content ends) -- so a test that never
/// draws would watch `amt` never move and read that as a broken
/// gesture.
fn area() -> (Fixture, WidgetId) {
area_on(Axis::Y)
}
/// The same fixture on either axis -- a code fence pans sideways
/// through one of these exactly as a field pans down, and the pair of
/// them is what caught a fling that only worked vertically.
fn area_on(axis: Axis) -> (Fixture, WidgetId) {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
let id = fill.id();
let long = Some(Len::abs(1000.0));
let tall = rsc.ui.widgets.add_strong(Sized {
inner: fill,
x: (axis == Axis::X).then_some(long).flatten(),
y: (axis == Axis::Y).then_some(long).flatten(),
});
let area = rsc
.ui
.widgets
.add_strong(ScrollArea::new(tall.any(), axis, Pin::Start));
let weak = area.weak();
let root = area.any();
let mut render = UiRenderState::new();
render.resize((100.0, 100.0));
render.update(&root, &mut rsc);
let mut fixture = Fixture {
rsc,
area: weak,
root,
render,
};
// 400px in, which is the middle of the 900px of travel this
// content has.
fixture.get().scroll(-400.0);
fixture.draw();
assert!((fixture.amt() - 400.0).abs() < 0.01);
(fixture, id)
}
/// The area under test with everything needed to draw it -- the drag
/// tests all do the same three things (reach the widget, draw, read
/// `amt`) and each of the three is a line of arena plumbing.
struct Fixture {
rsc: TestRsc,
area: WeakWidget<ScrollArea>,
root: StrongWidget,
render: UiRenderState,
}
impl Fixture {
fn get(&mut self) -> &mut ScrollArea {
self.rsc.ui.widgets.get_mut(&self.area).unwrap()
}
fn draw(&mut self) {
self.render.update(&self.root, &mut self.rsc);
}
fn amt(&self) -> f32 {
self.rsc.ui.widgets.get(&self.area).unwrap().amt()
}
/// One frame of a fling, the way `UiData::tick_animations` drives
/// it: tick, then draw. Answers whether it is still going.
fn fling_frame(&mut self, now: Instant) -> bool {
let still = self.get().tick(now);
self.draw();
still
}
}
/// One frame of a touch gesture, followed by the draw that spends it.
fn press(f: &mut Fixture, id: WidgetId, sense: CursorSense, y: f32, t: Instant) {
drag(f, id, sense, Vec2::new(0.0, y), t);
}
/// The same, for a gesture whose position is not on the Y axis.
fn drag(f: &mut Fixture, id: WidgetId, sense: CursorSense, pos: Vec2, t: Instant) {
let pointer = PointerRequests::default();
let flung = f.get().drag(&pointer, id, sense, pos, t);
// What `WidgetLike::scrollable`'s own handler does with the
// answer, and the half a fling does not move without.
if flung {
let id = f.area.id();
f.rsc.ui.animate(id);
}
f.draw();
}
#[test]
fn a_vertical_finger_drag_pans_the_content_with_the_finger() {
let (mut f, id) = area();
let t = Instant::now();
press(
&mut f,
id,
CursorSense::PressStart(CursorButton::Left),
0.0,
t,
);
// Finger down by well past the slop: the content follows it down,
// which for this widget means *less* `amt`.
press(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
DRAG_SLOP + 30.0,
t + Duration::from_millis(20),
);
assert!(
(f.amt() - 370.0).abs() < 0.01,
"expected the 30px past the slop to be applied downward, got amt={}",
f.amt()
);
// ...and the next frame's motion is a plain per-frame delta.
press(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
DRAG_SLOP + 50.0,
t + Duration::from_millis(40),
);
assert!((f.amt() - 350.0).abs() < 0.01, "amt={}", f.amt());
}
/// The half the change had no reason to touch: a press that never
/// leaves the slop is a tap, and must move nothing at all -- otherwise
/// every tap on a scrollable field nudges its text.
#[test]
fn a_press_that_stays_inside_the_slop_does_not_scroll() {
let (mut f, id) = area();
let t = Instant::now();
press(
&mut f,
id,
CursorSense::PressStart(CursorButton::Left),
0.0,
t,
);
for (i, y) in [1.0, -2.0, DRAG_SLOP - 0.5].into_iter().enumerate() {
press(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
y,
t + Duration::from_millis(10 * (i as u64 + 1)),
);
}
press(
&mut f,
id,
CursorSense::PressEnd(CursorButton::Left),
DRAG_SLOP - 0.5,
t + Duration::from_millis(50),
);
assert!(
(f.amt() - 400.0).abs() < 0.01,
"a tap scrolled: amt={}",
f.amt()
);
}
/// A horizontal drag is not this widget's gesture: it must stay put
/// rather than pick up the vertical noise in a sideways swipe.
#[test]
fn a_horizontal_drag_does_not_scroll() {
let (mut f, id) = area();
let t = Instant::now();
drag(
&mut f,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::new(0.0, 0.0),
t,
);
drag(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(120.0, 3.0),
t + Duration::from_millis(20),
);
assert!((f.amt() - 400.0).abs() < 0.01, "amt={}", f.amt());
}
/// Panning stops at the ends of the content rather than running off,
/// which is `update_amt`'s clamp -- checked through `drag` so the two
/// cannot drift apart.
#[test]
fn a_pan_past_the_end_clamps_instead_of_running_off() {
let (mut f, id) = area();
let t = Instant::now();
drag(
&mut f,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::new(0.0, 0.0),
t,
);
drag(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 5000.0),
t + Duration::from_millis(20),
);
assert!((f.amt() - 0.0).abs() < 0.01, "amt={}", f.amt());
}
/// Iris, 2026-09-08: "Flinging doesn't work in horizontal scroll
/// areas. Flinging should be enabled by default in all scroll areas
/// on android to match composes behavior." A release with real
/// velocity coasts, decelerating, and settles on its own.
#[test]
fn a_released_pan_flings_and_settles() {
for axis in [Axis::X, Axis::Y] {
let (mut f, id) = area_on(axis);
let t = Instant::now();
let at = |d: f32| Vec2::from_axis(axis, d, 0.0);
drag(
&mut f,
id,
CursorSense::PressStart(CursorButton::Left),
at(0.0),
t,
);
// Four samples 8ms apart, accelerating away from the start --
// three is the fewest `VelocityTracker`'s quadratic fit can
// use, so this is a gesture that genuinely has a velocity.
for (i, d) in [-40.0, -100.0, -180.0, -280.0].into_iter().enumerate() {
drag(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
at(d),
t + Duration::from_millis(8 * (i as u64 + 1)),
);
}
let at_release = f.amt();
drag(
&mut f,
id,
CursorSense::PressEnd(CursorButton::Left),
at(-280.0),
t + Duration::from_millis(32),
);
assert!(
f.get().is_scrolling(),
"{axis:?}: a released pan with velocity must fling"
);
// Frames at 8ms until it stops, with each step no longer than
// the one before it -- a coast that does not decelerate is
// the linear-spline bug this crate has had once already.
let mut last_step = f32::INFINITY;
let mut ticks = 0;
let mut now = t + Duration::from_millis(32);
while f.fling_frame(now) {
let before = f.amt();
now += Duration::from_millis(8);
f.fling_frame(now);
let step = (f.amt() - before).abs();
assert!(
step <= last_step + 0.01,
"{axis:?}: the fling sped up: {last_step} then {step}"
);
last_step = step;
ticks += 1;
assert!(ticks < 10_000, "{axis:?}: the fling never settled");
}
assert!(
f.amt() > at_release,
"{axis:?}: the fling moved the content the wrong way: {at_release} -> {}",
f.amt()
);
}
}
/// The wall: a fling must not spend its remaining distance on content
/// that is not there. Released hard toward the start, it settles
/// exactly on it.
#[test]
fn a_fling_stops_at_the_end_of_the_content() {
// Both walls. A positive delta is applied as `amt -= delta`, so a
// positive velocity runs toward the start of the content and a
// negative one toward its end; 1000px of content in a 100px box
// leaves `amt` in 0..=900.
for (velocity, wall) in [(50_000.0f32, 0.0f32), (-50_000.0, 900.0)] {
let (mut f, _id) = area();
f.get().fling(velocity);
let t = Instant::now();
let mut now = t;
for _ in 0..1_000 {
if !f.fling_frame(now) {
break;
}
now += Duration::from_millis(8);
}
assert!(
!f.get().is_scrolling(),
"the fling toward {wall} ran past the content"
);
assert!(
(f.amt() - wall).abs() < 0.01,
"it should have settled on {wall}, got amt={}",
f.amt()
);
}
}
/// A finger on coasting content stops it there, from the first
/// sample, with no `DRAG_SLOP` to wait out -- the catch
/// `DragArbiter::press_start` describes, which a scroll area needs
/// for the same reason a list does now that it can coast at all.
#[test]
fn a_press_on_a_coasting_area_catches_it() {
let (mut f, id) = area();
f.get().fling(-4_000.0);
let t = Instant::now();
f.fling_frame(t);
f.fling_frame(t + Duration::from_millis(8));
let caught_at = f.amt();
assert!(f.get().is_scrolling(), "the fixture must still be moving");
let down = t + Duration::from_millis(16);
drag(
&mut f,
id,
CursorSense::PressStart(CursorButton::Left),
Vec2::new(0.0, 0.0),
down,
);
assert!(!f.get().is_scrolling(), "a touch-down must end the fling");
assert!(
(f.amt() - caught_at).abs() < 0.01,
"the down itself must not move the content, only stop it"
);
// A move well under `DRAG_SLOP` still tracks the finger, because
// this press caught something that was moving.
drag(
&mut f,
id,
CursorSense::Pressing(CursorButton::Left),
Vec2::new(0.0, 2.0),
down + Duration::from_millis(8),
);
assert!(
(f.amt() - (caught_at - 2.0)).abs() < 0.01,
"a caught press must pan from its first sample: {} -> {}",
caught_at,
f.amt()
);
}
}