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>
232 lines
10 KiB
Rust
232 lines
10 KiB
Rust
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));
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|