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
7b54aaf3c4
commit
a9312e9431
113 files changed
+23221
-2992
No files matched your search
+428
@@ -0,0 +1,428 @@
|
||||
//! Layer 1 of docs/RUST.md's "Three test layers": a whole screen driven
|
||||
//! in-process with **no window, no compositor and no GPU**, on an
|
||||
//! explicit clock and a replayed touch stream.
|
||||
//!
|
||||
//! `layout_tests.rs` and `sense_tests.rs` already build trees over
|
||||
//! `UiRenderState` with a hand-rolled `Rsc` each; this is the same idea
|
||||
//! carried far enough to open a real app screen (`transcript-ui`'s, over
|
||||
//! the bench fixture -- see the `transcript-fixture` crate) at the
|
||||
//! phone's size and density, feed it a recorded flick, and assert on
|
||||
//! where the list ended up. What it answers that the emulator cannot:
|
||||
//! Android batches a 120Hz flick into one or two `MotionEvent`s
|
||||
//! (`CursorState::time`), and a `ui-trace` swipe is many evenly-spaced
|
||||
//! ones -- so the gesture shape a finger actually makes is only
|
||||
//! reproducible from a *file* of timestamped samples.
|
||||
//!
|
||||
//! It is a third backend in the sense `default/` and `android/` are, and
|
||||
//! deliberately the smallest one: the platform half of each of those
|
||||
//! (a surface, an IME, a URL opener) becomes a recorded fact here --
|
||||
//! [`HarnessState::keyboard_shown`], [`HarnessState::opened_urls`] --
|
||||
//! so a test can assert the platform *was asked*, which is the only
|
||||
//! thing either backend does with those calls anyway.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||
//! let screen = transcript_ui::build(&mut h.rsc, &mut h.state, rows);
|
||||
//! h.frame(0);
|
||||
//! h.replay(&TouchScript::parse(include_str!("flick.touch"))?);
|
||||
//! h.frames_until(20, 2_000, 8);
|
||||
//! ```
|
||||
|
||||
use crate::prelude::*;
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// One replayed pointer sample: what Android's `MotionEvent` carries, cut
|
||||
/// down to the part iris reads (`IrisViewPeer::on_touch_event`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TouchAction {
|
||||
Down,
|
||||
Move,
|
||||
Up,
|
||||
/// The gesture taken away by the system (a parent view claiming it, a
|
||||
/// call arriving, the swipe up from the bottom edge to leave the
|
||||
/// app). It ends the press, because a release that never arrives
|
||||
/// leaves pointer capture held forever -- but it is not a release,
|
||||
/// and nothing follows from it: no tap, no selection, no fling. See
|
||||
/// `CursorState::cancelled`, which is what it sets.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
impl TouchAction {
|
||||
fn parse(word: &str) -> Option<Self> {
|
||||
match word {
|
||||
"down" => Some(Self::Down),
|
||||
"move" => Some(Self::Move),
|
||||
"up" => Some(Self::Up),
|
||||
"cancel" => Some(Self::Cancel),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The inverse of [`Self::parse`] -- what [`Harness::touch`] hands
|
||||
/// [`crate::sense::log_input_event`], so an `iris::input` line and a
|
||||
/// `.touch` file agree on one spelling of each action.
|
||||
pub fn word(self) -> &'static str {
|
||||
match self {
|
||||
Self::Down => "down",
|
||||
Self::Move => "move",
|
||||
Self::Up => "up",
|
||||
Self::Cancel => "cancel",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct TouchSample {
|
||||
/// Milliseconds since the start of the recording -- the sample's own
|
||||
/// time, which becomes `CursorState::time`. See that field's doc for
|
||||
/// why a replay may not date its samples by when the loop got to
|
||||
/// them.
|
||||
pub t_ms: u64,
|
||||
pub action: TouchAction,
|
||||
pub pos: Vec2,
|
||||
}
|
||||
|
||||
/// A recorded gesture: one `t_ms action x y` line per sample, `#` and
|
||||
/// blank lines ignored. Deliberately a plain text file rather than a
|
||||
/// serialisation format -- it is written by hand as often as it is
|
||||
/// recorded, and a diff of one has to be readable.
|
||||
pub struct TouchScript {
|
||||
pub samples: Vec<TouchSample>,
|
||||
}
|
||||
|
||||
impl TouchScript {
|
||||
/// Parses a script, naming the line and what was wrong with it: these
|
||||
/// are hand-written files, so a typo is the ordinary case and
|
||||
/// "expected 4 fields" without a line number is not enough to fix it.
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let mut samples: Vec<TouchSample> = Vec::new();
|
||||
for (i, line) in text.lines().enumerate() {
|
||||
let line = line.split('#').next().unwrap_or("").trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let at = |what: &str| format!("touch script line {}: {what}: {line:?}", i + 1);
|
||||
let mut words = line.split_whitespace();
|
||||
let (Some(t), Some(action), Some(x), Some(y), None) = (
|
||||
words.next(),
|
||||
words.next(),
|
||||
words.next(),
|
||||
words.next(),
|
||||
words.next(),
|
||||
) else {
|
||||
return Err(at("expected `t_ms action x y`"));
|
||||
};
|
||||
let t_ms: u64 = t.parse().map_err(|_| at("t_ms is not a whole number"))?;
|
||||
let action = TouchAction::parse(action)
|
||||
.ok_or_else(|| at("action is not down/move/up/cancel"))?;
|
||||
let x: f32 = x.parse().map_err(|_| at("x is not a number"))?;
|
||||
let y: f32 = y.parse().map_err(|_| at("y is not a number"))?;
|
||||
if let Some(last) = samples.last()
|
||||
&& t_ms < last.t_ms
|
||||
{
|
||||
return Err(at("samples must be in time order"));
|
||||
}
|
||||
samples.push(TouchSample {
|
||||
t_ms,
|
||||
action,
|
||||
pos: Vec2::new(x, y),
|
||||
});
|
||||
}
|
||||
Ok(Self { samples })
|
||||
}
|
||||
|
||||
/// The last sample's time, i.e. how long the recording runs.
|
||||
pub fn end_ms(&self) -> u64 {
|
||||
self.samples.last().map(|s| s.t_ms).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts the frames something asked for without drawing any -- the
|
||||
/// harness's `RequestRedraw`. A `LazySpan` coasting through a fling asks for
|
||||
/// the next frame through this (`UiData::animate` and `Widget::tick`), so a test can
|
||||
/// tell "nothing moved" from "nothing was even asked to move".
|
||||
#[derive(Default)]
|
||||
pub struct RedrawCounter(AtomicUsize);
|
||||
|
||||
impl RedrawCounter {
|
||||
pub fn count(&self) -> usize {
|
||||
self.0.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestRedraw for RedrawCounter {
|
||||
fn request_redraw(&self) {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The harness's app state: what each real backend keeps for the platform
|
||||
/// half, recorded instead of performed.
|
||||
pub struct HarnessState {
|
||||
pub root: Option<StrongWidget>,
|
||||
pub focus: Option<WeakWidget<TextEdit>>,
|
||||
last_click: Instant,
|
||||
/// How many times a tap asked for the keyboard (`FocusHost::
|
||||
/// focus_gained` with a region -- `showSoftInput` on Android,
|
||||
/// `set_ime_cursor_area` on winit). The platform's own answer is not
|
||||
/// available here, so this says what was *asked*, and a test must not
|
||||
/// read it as "the IME is up".
|
||||
pub keyboard_shown: usize,
|
||||
/// Every URL a tapped link asked the platform to open, in order.
|
||||
pub opened_urls: Vec<String>,
|
||||
}
|
||||
|
||||
impl HarnessState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
root: None,
|
||||
focus: None,
|
||||
last_click: Instant::now(),
|
||||
keyboard_shown: 0,
|
||||
opened_urls: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HasRoot for HarnessState {
|
||||
fn set_root(&mut self, root: StrongWidget) {
|
||||
self.root = Some(root);
|
||||
}
|
||||
}
|
||||
|
||||
impl FocusHost for HarnessState {
|
||||
fn recent_click(&mut self) -> bool {
|
||||
crate::attr::recent_click(&mut self.last_click)
|
||||
}
|
||||
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>) {
|
||||
self.focus = id;
|
||||
}
|
||||
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool {
|
||||
self.focus == Some(id)
|
||||
}
|
||||
fn focus_gained(&mut self, region: Option<PixelRegion>) {
|
||||
if region.is_some() {
|
||||
self.keyboard_shown += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenUrl for HarnessState {
|
||||
fn open_url(&mut self, url: &str) {
|
||||
self.opened_urls.push(url.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// The harness's `Rsc` -- identical in substance to `DefaultRsc`/
|
||||
/// `AndroidRsc` minus the windowing, for the same reason those two are
|
||||
/// separate types (`AndroidRsc`'s own doc).
|
||||
pub struct HarnessRsc {
|
||||
pub ui: UiData,
|
||||
pub events: EventManager<Self>,
|
||||
pub tasks: Tasks<Self>,
|
||||
pub state: WidgetState,
|
||||
_state: PhantomData<HarnessState>,
|
||||
}
|
||||
|
||||
impl UiRsc for HarnessRsc {
|
||||
fn ui(&self) -> &UiData {
|
||||
&self.ui
|
||||
}
|
||||
fn ui_mut(&mut self) -> &mut UiData {
|
||||
&mut self.ui
|
||||
}
|
||||
fn on_draw(&mut self, active: &ActiveData) {
|
||||
self.events.draw(active);
|
||||
}
|
||||
fn on_undraw(&mut self, active: &ActiveData) {
|
||||
self.events.undraw(active);
|
||||
}
|
||||
fn on_remove(&mut self, id: WidgetId) {
|
||||
self.events.remove(id);
|
||||
self.state.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
impl HasState for HarnessRsc {
|
||||
type State = HarnessState;
|
||||
}
|
||||
|
||||
impl HasEvents for HarnessRsc {
|
||||
fn events(&self) -> &EventManager<Self> {
|
||||
&self.events
|
||||
}
|
||||
fn events_mut(&mut self) -> &mut EventManager<Self> {
|
||||
&mut self.events
|
||||
}
|
||||
}
|
||||
|
||||
impl HasTasks for HarnessRsc {
|
||||
fn tasks_mut(&mut self) -> &mut Tasks<Self> {
|
||||
&mut self.tasks
|
||||
}
|
||||
}
|
||||
|
||||
impl HasWidgetState for HarnessRsc {
|
||||
fn widget_state(&self) -> &WidgetState {
|
||||
&self.state
|
||||
}
|
||||
fn widget_state_mut(&mut self) -> &mut WidgetState {
|
||||
&mut self.state
|
||||
}
|
||||
}
|
||||
|
||||
impl<I: RscIdx<HarnessRsc>> std::ops::Index<I> for HarnessRsc {
|
||||
type Output = I::Output;
|
||||
fn index(&self, index: I) -> &Self::Output {
|
||||
index.get(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<I: RscIdx<HarnessRsc>> std::ops::IndexMut<I> for HarnessRsc {
|
||||
fn index_mut(&mut self, index: I) -> &mut Self::Output {
|
||||
index.get_mut(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// A screen running with no window: the widget tree, the frame loop and
|
||||
/// the pointer, all advanced by the caller. See the module doc.
|
||||
pub struct Harness {
|
||||
pub rsc: HarnessRsc,
|
||||
pub render: UiRenderState,
|
||||
pub state: HarnessState,
|
||||
task_recv: TaskMsgReceiver<HarnessRsc>,
|
||||
redraws: Arc<RedrawCounter>,
|
||||
cursor: CursorState,
|
||||
/// Time zero. Every `t_ms` in this harness is an offset from here, so
|
||||
/// nothing reads the wall clock -- see [`Self::at`].
|
||||
base: Instant,
|
||||
size: Vec2,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
/// `size` is in physical pixels and `density` is physical pixels per
|
||||
/// dp, the pair Android reads from the surface and
|
||||
/// `DisplayMetrics.density` (`AndroidUiState::content_scale`). The
|
||||
/// phone's own numbers are `transcript_fixture::PHONE_SIZE`/
|
||||
/// `PHONE_SCALE`.
|
||||
pub fn new(size: Vec2, density: f32) -> Self {
|
||||
let redraws = Arc::new(RedrawCounter::default());
|
||||
let (tasks, task_recv) = Tasks::init(redraws.clone());
|
||||
let mut rsc = HarnessRsc {
|
||||
ui: UiData::default(),
|
||||
events: EventManager::default(),
|
||||
tasks,
|
||||
state: WidgetState::default(),
|
||||
_state: PhantomData,
|
||||
};
|
||||
rsc.ui.text.density = density;
|
||||
let mut render = UiRenderState::new();
|
||||
render.set_density(density);
|
||||
render.resize(size);
|
||||
Self {
|
||||
rsc,
|
||||
render,
|
||||
state: HarnessState::new(),
|
||||
task_recv,
|
||||
redraws,
|
||||
cursor: CursorState::default(),
|
||||
base: Instant::now(),
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Instant` this harness means by `t_ms`. Public because a
|
||||
/// caller driving `ScrollController::tick` or `DragGesture` by hand needs
|
||||
/// to date those calls on the same clock the touch samples use.
|
||||
pub fn at(&self, t_ms: u64) -> Instant {
|
||||
self.base + Duration::from_millis(t_ms)
|
||||
}
|
||||
|
||||
pub fn size(&self) -> Vec2 {
|
||||
self.size
|
||||
}
|
||||
|
||||
/// How many frames were asked for so far -- see [`RedrawCounter`].
|
||||
pub fn redraws(&self) -> usize {
|
||||
self.redraws.count()
|
||||
}
|
||||
|
||||
/// One frame at `t_ms`: drain finished tasks, advance anything
|
||||
/// animating, lay out and "draw". The same three steps
|
||||
/// `DefaultApp::window_event`'s `RedrawRequested` arm and
|
||||
/// `IrisViewPeer::render` take, minus handing primitives to a GPU.
|
||||
pub fn frame(&mut self, t_ms: u64) {
|
||||
while let Ok(update) = self.task_recv.try_recv() {
|
||||
update(&mut self.state, &mut self.rsc);
|
||||
}
|
||||
let now = self.at(t_ms);
|
||||
let animating = self.rsc.ui.tick_animations(now);
|
||||
self.render.update(&self.state.root, &mut self.rsc);
|
||||
// No GPU here, so there is no draw phase to time -- `draw` is
|
||||
// always zero. `layout`/`redraw`/`primitives` are still real,
|
||||
// because `render.update` just ran; see
|
||||
// `iris::diagnostics::log_frame`'s own doc for why this reads
|
||||
// those back rather than timing anything itself.
|
||||
crate::diagnostics::log_frame(&self.render, now, Duration::ZERO, animating);
|
||||
}
|
||||
|
||||
/// Frames every `step_ms` up to and including `end_ms` -- what a
|
||||
/// fling needs, since it moves only while something ticks it
|
||||
/// (`ScrollController::fling`'s doc). Returns the time of the last frame run.
|
||||
pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 {
|
||||
debug_assert!(step_ms > 0, "a frame loop with no step never ends");
|
||||
let mut t = from_ms;
|
||||
while t <= end_ms {
|
||||
self.frame(t);
|
||||
t += step_ms;
|
||||
}
|
||||
t - step_ms
|
||||
}
|
||||
|
||||
/// One pointer sample through the sensors, then the frame it belongs
|
||||
/// to -- `IrisViewPeer::on_touch_event` and `after_input`, in one
|
||||
/// call. Each sample is its own input frame, dated by the sample
|
||||
/// rather than by when this ran.
|
||||
pub fn touch(&mut self, action: TouchAction, pos: Vec2, t_ms: u64) {
|
||||
self.cursor.time = self.at(t_ms);
|
||||
self.cursor.pos = pos;
|
||||
match action {
|
||||
TouchAction::Down => {
|
||||
self.cursor.exists = true;
|
||||
self.cursor.buttons.left.update(true);
|
||||
}
|
||||
TouchAction::Move => {}
|
||||
TouchAction::Up => self.cursor.buttons.left.update(false),
|
||||
// The platform taking the gesture away, not the finger
|
||||
// lifting -- see `CursorState::cancelled`.
|
||||
TouchAction::Cancel => {
|
||||
self.cursor.buttons.left.update(false);
|
||||
self.cursor.cancelled = true;
|
||||
}
|
||||
}
|
||||
// Layer 1's half of `iris::input` (`sense::log_input_event`'s own
|
||||
// doc): no batching happens here, so `historical` is always empty
|
||||
// and `t_ms` is the script's own column, which is what makes this
|
||||
// round-trip through `report_to_touch.py` back into an identical
|
||||
// `TouchScript`.
|
||||
crate::sense::log_input_event(action.word(), pos.x, pos.y, t_ms, &[]);
|
||||
let cursor = self.cursor.clone();
|
||||
self.render
|
||||
.run_sensors(&mut self.rsc, &mut self.state, cursor, self.size);
|
||||
self.frame(t_ms);
|
||||
self.cursor.end_frame();
|
||||
}
|
||||
|
||||
/// Replays a whole recorded gesture. Nothing is inserted between the
|
||||
/// samples: a file with three lines produces three input frames, so
|
||||
/// the batched shape a real flick arrives in is preserved exactly as
|
||||
/// recorded rather than smoothed into evenly-spaced motion.
|
||||
pub fn replay(&mut self, script: &TouchScript) {
|
||||
for sample in &script.samples {
|
||||
self.touch(sample.action, sample.pos, sample.t_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user