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
+231
@@ -0,0 +1,231 @@
|
||||
use crate::prelude::*;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// What focusing a text field takes from whichever backend is running --
|
||||
/// tracked here rather than duplicated per backend, since `Selector` and
|
||||
/// `Selectable` (below) are the *only* thing that decides which `TextEdit`
|
||||
/// is the IME's target, and both platforms need the same double-click
|
||||
/// timing and the same "remember which one" bookkeeping. What differs is
|
||||
/// what happens *after* the focus record is set: winit tells the
|
||||
/// compositor an IME area (`focus_gained`, in `default/attr.rs`); on
|
||||
/// android-view a keyboard has to be asked for explicitly, and only from a
|
||||
/// JNI call this crate cannot make outside a view callback -- so
|
||||
/// `focus_gained` there (`android/attr.rs`) just raises a flag the next
|
||||
/// touch callback consumes. See RUST.md's I2.
|
||||
pub trait FocusHost {
|
||||
/// True on a click close enough in time to the previous one to grow a
|
||||
/// selection instead of starting a new one, updating the clock as a
|
||||
/// side effect the way a real double-click timer does.
|
||||
fn recent_click(&mut self) -> bool;
|
||||
fn set_focus(&mut self, id: Option<WeakWidget<TextEdit>>);
|
||||
/// Called on every tap that should put the IME on `id`: the tap that
|
||||
/// *makes* a `TextEdit` the focus target, and any later tap on one that
|
||||
/// already is. `region` is where it was hit (`None` when the widget
|
||||
/// could not be located, which happens for one it was just deselected
|
||||
/// from). Implementations must be idempotent -- both backends' calls
|
||||
/// (`showSoftInput`, `set_ime_cursor_area`) already are, which is what
|
||||
/// lets the repeat tap be handled by the same call rather than by a
|
||||
/// second "re-show" entry point beside it.
|
||||
fn focus_gained(&mut self, region: Option<PixelRegion>);
|
||||
/// Whether `id` is the current focus target -- what [`select`] uses to
|
||||
/// tell a fresh press (which must wait to see whether it becomes a tap
|
||||
/// or a drag before focusing/showing the IME, Iris 2026-09-06: "if I
|
||||
/// swipe over the input bar it brings up the keyboard") from a drag
|
||||
/// continuing inside a field that was already focused (an ordinary
|
||||
/// drag-to-select, unaffected).
|
||||
fn is_focused(&self, id: WeakWidget<TextEdit>) -> bool;
|
||||
}
|
||||
|
||||
/// Helper shared by every `FocusHost` impl, so the double-click window is
|
||||
/// one constant rather than one per backend.
|
||||
pub fn recent_click(last_click: &mut Instant) -> bool {
|
||||
let now = Instant::now();
|
||||
let recent = (now - *last_click) < Duration::from_millis(300);
|
||||
*last_click = now;
|
||||
recent
|
||||
}
|
||||
|
||||
/// `PressStart`/`Pressing`/`PressEnd`, all for the left button -- what
|
||||
/// [`Selector`]/[`Selectable`] register instead of [`CursorSense::
|
||||
/// click_or_drag`], so their shared handler (`on_press`, below) sees every
|
||||
/// frame of a gesture and can tell a completed tap from a drag itself,
|
||||
/// rather than reacting to `PressStart` alone the way `click_or_drag`'s
|
||||
/// consumer used to (Iris, 2026-09-06: "if I swipe over the input bar it
|
||||
/// brings up the keyboard").
|
||||
/// `CursorSense::Cancel` is in the set for the same reason `DragGesture`
|
||||
/// registers it: if a scroll area or a list takes the pointer mid-gesture,
|
||||
/// this field sees no `PressEnd`, and a `press_origin` left set is then
|
||||
/// compared against the *next* press -- a stray selection, or a keyboard
|
||||
/// summoned by a tap somewhere else entirely.
|
||||
fn press_track() -> CursorSenses {
|
||||
CursorSense::click()
|
||||
| CursorSense::Pressing(CursorButton::Left)
|
||||
| CursorSense::unclick()
|
||||
| CursorSense::Cancel
|
||||
}
|
||||
|
||||
pub struct Selector;
|
||||
|
||||
impl<Rsc: HasEvents, W: Widget + 'static> WidgetAttr<Rsc, W> for Selector
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
type Input = WeakWidget<TextEdit>;
|
||||
|
||||
fn run(rsc: &mut Rsc, container: WeakWidget<W>, id: Self::Input) {
|
||||
rsc.register_event(container, press_track(), move |ctx, rsc| {
|
||||
let region = ctx.data.render.window_region(&id, &*rsc).unwrap();
|
||||
let id_pos = region.top_left;
|
||||
let container_pos = ctx
|
||||
.data
|
||||
.render
|
||||
.window_region(&container, &*rsc)
|
||||
.unwrap()
|
||||
.top_left;
|
||||
let pos = ctx.data.pos + container_pos - id_pos;
|
||||
let size = region.size();
|
||||
on_press(
|
||||
rsc,
|
||||
ctx.data.render,
|
||||
ctx.state,
|
||||
id,
|
||||
pos,
|
||||
size,
|
||||
ctx.data.sense,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Selectable;
|
||||
|
||||
impl<Rsc: HasEvents> WidgetAttr<Rsc, TextEdit> for Selectable
|
||||
where
|
||||
Rsc::State: FocusHost,
|
||||
{
|
||||
type Input = ();
|
||||
|
||||
fn run(rsc: &mut Rsc, id: WeakWidget<TextEdit>, _: Self::Input) {
|
||||
rsc.register_event(id, press_track(), move |ctx, rsc| {
|
||||
on_press(
|
||||
rsc,
|
||||
ctx.data.render,
|
||||
ctx.state,
|
||||
id,
|
||||
ctx.data.pos,
|
||||
ctx.data.size,
|
||||
ctx.data.sense,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// One press-track frame (`PressStart`, `Pressing` or `PressEnd`) over a
|
||||
/// selectable field. A field that is *already* focused behaves exactly as
|
||||
/// `click_or_drag` always did -- every frame updates the selection, which
|
||||
/// is what lets a finger already inside a focused field drag out a
|
||||
/// selection. A field that is **not** focused withholds `select`'s
|
||||
/// focus-granting side effects (and so the platform-specific `focus_gained`
|
||||
/// that shows the keyboard) until the press resolves as a tap: `PressEnd`
|
||||
/// with no frame in between having moved past [`DRAG_SLOP`] from where the
|
||||
/// press began. A drag recognised before release simply cancels the
|
||||
/// pending tap and does nothing further here -- it is not consumed, so
|
||||
/// whatever is behind the field (a list to pan) still sees every frame of
|
||||
/// it, the same as a drag that never touched a selectable field at all.
|
||||
fn on_press(
|
||||
rsc: &mut impl UiRsc,
|
||||
render: &UiRenderState,
|
||||
state: &mut impl FocusHost,
|
||||
id: WeakWidget<TextEdit>,
|
||||
pos: Vec2,
|
||||
size: Vec2,
|
||||
sense: CursorSense,
|
||||
) {
|
||||
if state.is_focused(id) {
|
||||
// Already focused, so there is no keyboard to withhold -- but a
|
||||
// vertical drag still is not a selection. Android's own `EditText`
|
||||
// scrolls its overflowed text on a vertical drag and starts a
|
||||
// selection only from a long press; a scroll area wrapping this
|
||||
// field (`ScrollController::drag`) is what actually pans, and it needs the
|
||||
// first frames of the gesture not to have selected anything behind
|
||||
// it before it crosses `DRAG_SLOP` and takes pointer capture.
|
||||
// `press_origin` carries the same meaning here as in the unfocused
|
||||
// branch below -- "this gesture is still eligible", cleared the
|
||||
// moment it becomes a drag -- so there is one flag, not two.
|
||||
match sense {
|
||||
CursorSense::PressStart(_) => {
|
||||
let recent = state.recent_click();
|
||||
id.edit(rsc).text.press_origin = Some(pos);
|
||||
id.edit(rsc).select(pos, size, false, recent);
|
||||
}
|
||||
CursorSense::Pressing(_) | CursorSense::PressEnd(_) => {
|
||||
let mut ctx = id.edit(rsc);
|
||||
let Some(origin) = ctx.text.press_origin else {
|
||||
return;
|
||||
};
|
||||
let (dx, dy) = (pos.x - origin.x, pos.y - origin.y);
|
||||
if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() {
|
||||
ctx.text.press_origin = None;
|
||||
return;
|
||||
}
|
||||
let ended = matches!(sense, CursorSense::PressEnd(_));
|
||||
if ended {
|
||||
ctx.text.press_origin = None;
|
||||
}
|
||||
ctx.select(pos, size, true, false);
|
||||
// A tap on a field that is *already* focused asks for the
|
||||
// keyboard again (Iris's phone, 2026-09-06: "I can't reopen
|
||||
// keyboard by tapping on message box after it already
|
||||
// happened once"). Dismissing the IME -- back gesture, or
|
||||
// its own hide button -- takes the keyboard away but leaves
|
||||
// the field focused, so without this the one branch that
|
||||
// requests it (the unfocused one below) never runs again
|
||||
// and the field is permanently unable to summon it.
|
||||
// Android's own `EditText` does exactly this: every tap on
|
||||
// a focused field calls `showSoftInput`, which is a no-op
|
||||
// when the keyboard is already up.
|
||||
//
|
||||
// Gated on the same tap-vs-drag test the unfocused branch
|
||||
// uses, not on `PressEnd` alone, so a drag-to-select that
|
||||
// happens to finish inside the field does not summon a
|
||||
// keyboard the reader was not asking for.
|
||||
if ended && dx.abs() <= DRAG_SLOP && dy.abs() <= DRAG_SLOP {
|
||||
state.focus_gained(render.window_region(&id, &*rsc));
|
||||
}
|
||||
}
|
||||
CursorSense::Cancel => id.edit(rsc).text.press_origin = None,
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match sense {
|
||||
CursorSense::PressStart(_) => {
|
||||
id.edit(rsc).text.press_origin = Some(pos);
|
||||
}
|
||||
CursorSense::Pressing(_) => {
|
||||
let ctx = id.edit(rsc);
|
||||
if let Some(origin) = ctx.text.press_origin
|
||||
&& ((pos.x - origin.x).abs() > DRAG_SLOP || (pos.y - origin.y).abs() > DRAG_SLOP)
|
||||
{
|
||||
// Past the slop before release: this is a drag, not a tap
|
||||
// -- give up the pending focus rather than granting it once
|
||||
// the finger lifts wherever it happens to be by then.
|
||||
ctx.text.press_origin = None;
|
||||
}
|
||||
}
|
||||
// The gesture was taken by somebody else, so it is not a tap and
|
||||
// must not grant focus when it ends out of this widget's sight.
|
||||
CursorSense::Cancel => id.edit(rsc).text.press_origin = None,
|
||||
CursorSense::PressEnd(_) => {
|
||||
let was_tap = id.edit(rsc).text.press_origin.take().is_some();
|
||||
if was_tap {
|
||||
let recent = state.recent_click();
|
||||
id.edit(rsc).select(pos, size, false, recent);
|
||||
state.set_focus(Some(id));
|
||||
state.focus_gained(render.window_region(&id, &*rsc));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user