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:
1 parent
e9a6562dc6
commit
6d5a231f5c
100 files changed
+924
-3295
No files matched your search
@@ -0,0 +1,230 @@
|
||||
//! Layer 1 for Iris's 2026-09-08 "flinging doesn't work in horizontal
|
||||
//! scroll areas": a real markdown fence in the real transcript screen,
|
||||
//! flicked sideways, has to keep moving after the finger leaves.
|
||||
//!
|
||||
//! The fence is pushed here rather than hunted for in the bench fixture,
|
||||
//! so the test knows which row it is pressing and where. The `ScrollArea` it
|
||||
//! asserts on is found by walking what is actually drawn -- there is no
|
||||
//! handle to it from the outside, and a coordinate would only prove that
|
||||
//! *something* moved.
|
||||
|
||||
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||
use iris::harness::{Harness, TouchAction};
|
||||
use iris::prelude::*;
|
||||
|
||||
/// The horizontal scroll area drawn inside `top..bottom`, with the box
|
||||
/// it was drawn at -- a fence is the only thing in a transcript that pans
|
||||
/// sideways. Found by walking what is actually drawn, because there is no
|
||||
/// handle to a fence's own `ScrollArea` from the outside and a bare
|
||||
/// coordinate would only prove that *something* moved.
|
||||
fn fence_scroll_in(h: &Harness, top: f32, bottom: f32) -> Option<(WidgetId, PixelRegion)> {
|
||||
h.render
|
||||
.active
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|&id| {
|
||||
h.rsc
|
||||
.ui
|
||||
.widgets
|
||||
.get_dyn(id)
|
||||
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
|
||||
.is_some_and(|s| s.axis() == Axis::X)
|
||||
})
|
||||
.find_map(|id| {
|
||||
let r = h.render.window_region(&id, &h.rsc)?;
|
||||
(r.top_left.y >= top && r.bot_right.y <= bottom).then_some((id, r))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_scrolling(h: &Harness, id: WidgetId) -> bool {
|
||||
h.rsc
|
||||
.ui
|
||||
.widgets
|
||||
.get_dyn(id)
|
||||
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
|
||||
.expect("the fence's scroll area is still drawn")
|
||||
.is_scrolling()
|
||||
}
|
||||
|
||||
fn amt(h: &Harness, id: WidgetId) -> f32 {
|
||||
h.rsc
|
||||
.ui
|
||||
.widgets
|
||||
.get_dyn(id)
|
||||
.and_then(|w| w.as_any().downcast_ref::<ScrollArea>())
|
||||
.expect("the fence's scroll area is still drawn")
|
||||
.amt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flick_across_a_code_fence_keeps_moving_after_the_finger_leaves() {
|
||||
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
|
||||
|
||||
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||
let screen = opened.screen;
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
|
||||
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
||||
seq: 9_000_000,
|
||||
text: "```\none two three four five six seven eight nine ten eleven twelve \
|
||||
thirteen fourteen fifteen sixteen seventeen eighteen twenty twentyone\n```"
|
||||
.to_string(),
|
||||
settled: true,
|
||||
});
|
||||
screen.push_row(&mut h.rsc, &fence);
|
||||
(screen.list)(&mut h.rsc).jump_to_end();
|
||||
h.frame(100);
|
||||
h.frame(108);
|
||||
|
||||
let key = ai_app::ui::row::row_key(&fence.key());
|
||||
let (top, bottom) = (screen.list)(&mut h.rsc)
|
||||
.extent(key)
|
||||
.expect("the fence row is on screen");
|
||||
let (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
|
||||
.expect("the pushed fence draws a horizontal scroll area of its own");
|
||||
assert_eq!(amt(&h, fence_scroll), 0.0, "a fence opens at its start");
|
||||
|
||||
// Down the middle of the fence's own box, so the press is on the
|
||||
// text inside the scroll area rather than on the row's sender label.
|
||||
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
|
||||
|
||||
// A flick sideways: four samples 8ms apart, accelerating, then the
|
||||
// finger leaves.
|
||||
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
|
||||
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
|
||||
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
|
||||
}
|
||||
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
|
||||
|
||||
let at_release = amt(&h, fence_scroll);
|
||||
assert!(
|
||||
at_release > 0.0,
|
||||
"the flick itself must have panned the fence, got {at_release}"
|
||||
);
|
||||
|
||||
// Frames for the next half second, with nothing touching the screen.
|
||||
let mut t = 240;
|
||||
while t <= 740 {
|
||||
h.frame(t);
|
||||
t += PHONE_FRAME_MS;
|
||||
}
|
||||
let coasted = amt(&h, fence_scroll);
|
||||
assert!(
|
||||
coasted > at_release + 1.0,
|
||||
"the fence stopped dead at the release: {at_release} -> {coasted}"
|
||||
);
|
||||
|
||||
// ...and it settles rather than running forever.
|
||||
let settled = coasted;
|
||||
while t <= 4_000 {
|
||||
h.frame(t);
|
||||
t += PHONE_FRAME_MS;
|
||||
}
|
||||
let after = amt(&h, fence_scroll);
|
||||
assert!(
|
||||
after >= settled,
|
||||
"a fling must not run backwards: {settled} -> {after}"
|
||||
);
|
||||
let last = after;
|
||||
h.frame(t);
|
||||
assert_eq!(last, amt(&h, fence_scroll), "the fling never settled");
|
||||
}
|
||||
|
||||
/// Iris's 2026-09-08 report: "if I try to scroll vertically while a
|
||||
/// horizontal scroll animation is still active, it stays locked to the
|
||||
/// horizontal scroll. It should let it keep going and instead only affect
|
||||
/// vertical scrolling."
|
||||
///
|
||||
/// Her own diagnosis was the right one -- "tapping outside of something
|
||||
/// that a fling is currently active for should have no code in common
|
||||
/// with the fling that could influence it" -- and
|
||||
/// `sense_tests::a_press_does_not_reach_a_widget_the_pointer_has_just_left`
|
||||
/// is the mechanism in isolation. This is the same thing over the real
|
||||
/// screen, which is where it was found: the finger goes down on an
|
||||
/// ordinary row 500px above a coasting fence, and what must move is the
|
||||
/// list, while the fence carries on coasting untouched.
|
||||
#[test]
|
||||
fn a_drag_away_from_a_coasting_fence_scrolls_the_list_and_leaves_it_coasting() {
|
||||
use ai_app::client::transcript_fold::{TranscriptItem, TranscriptRow};
|
||||
|
||||
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||
let opened = ai_app::ui::fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds");
|
||||
let screen = opened.screen;
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
|
||||
let fence = TranscriptRow::Single(TranscriptItem::AssistantMsg {
|
||||
seq: 9_000_000,
|
||||
text: format!(
|
||||
"```\n{}\n```",
|
||||
(1..=200)
|
||||
.map(|i| format!("word{i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
),
|
||||
settled: true,
|
||||
});
|
||||
screen.push_row(&mut h.rsc, &fence);
|
||||
(screen.list)(&mut h.rsc).jump_to_end();
|
||||
h.frame(100);
|
||||
h.frame(108);
|
||||
|
||||
let key = ai_app::ui::row::row_key(&fence.key());
|
||||
let (top, bottom) = (screen.list)(&mut h.rsc)
|
||||
.extent(key)
|
||||
.expect("the fence row is on screen");
|
||||
let (fence_scroll, box_) = fence_scroll_in(&h, top, bottom)
|
||||
.expect("the pushed fence draws a horizontal scroll area of its own");
|
||||
let y = (box_.top_left.y + box_.bot_right.y) / 2.0;
|
||||
|
||||
// Flick the fence sideways and let go, exactly as above.
|
||||
h.touch(TouchAction::Down, Vec2::new(900.0, y), 200);
|
||||
for (i, x) in [860.0, 800.0, 720.0, 620.0].into_iter().enumerate() {
|
||||
h.touch(TouchAction::Move, Vec2::new(x, y), 208 + 8 * i as u64);
|
||||
}
|
||||
h.touch(TouchAction::Up, Vec2::new(620.0, y), 240);
|
||||
h.frame(248);
|
||||
assert!(
|
||||
is_scrolling(&h, fence_scroll),
|
||||
"the fence has to still be coasting for this to be the reported case",
|
||||
);
|
||||
|
||||
// A row well clear of the fence, taken by its own extent rather than
|
||||
// by a coordinate: the gaps between rows are pointer-transparent, so a
|
||||
// y picked by hand lands on nothing often enough to make a green run
|
||||
// meaningless.
|
||||
let probe = box_.top_left.y - 500.0;
|
||||
let row = (screen.list)(&mut h.rsc)
|
||||
.key_at(probe)
|
||||
.expect("a row that far up the screen");
|
||||
let (row_top, row_bottom) = (screen.list)(&mut h.rsc).extent(row).expect("its extent");
|
||||
let from = (row_top + row_bottom) / 2.0;
|
||||
|
||||
let list_before = (screen.list)(&mut h.rsc).anchor_position_display();
|
||||
let fence_before = amt(&h, fence_scroll);
|
||||
h.touch(TouchAction::Down, Vec2::new(540.0, from), 256);
|
||||
let mut t = 264;
|
||||
for i in 1..=8 {
|
||||
h.touch(
|
||||
TouchAction::Move,
|
||||
Vec2::new(540.0, from + 20.0 * i as f32),
|
||||
t,
|
||||
);
|
||||
t += 8;
|
||||
}
|
||||
h.touch(TouchAction::Up, Vec2::new(540.0, from + 160.0), t);
|
||||
|
||||
assert_ne!(
|
||||
list_before,
|
||||
(screen.list)(&mut h.rsc).anchor_position_display(),
|
||||
"the drag was nowhere near the fence, so it belongs to the list",
|
||||
);
|
||||
assert!(
|
||||
amt(&h, fence_scroll) > fence_before,
|
||||
"the fence's fling must carry on through a gesture that was never \
|
||||
its own: {fence_before} -> {}",
|
||||
amt(&h, fence_scroll),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user